Multi-Environment AWS Network with Terraform
Build a reusable Terraform VPC module deployed to isolated dev and prod environments.
Introduction
30 Second Summary
When teams deploy cloud infrastructure by hand, every environment drifts apart over time. One forgotten setting in production can take down an entire application.
In this project, you will build a multi-environment AWS network using Terraform to provision identical infrastructure from reusable code. You will deploy separate dev and prod environments backed by a secure remote state system.
What You'll Build
A production-grade Terraform project that provisions AWS networking infrastructure (VPCs, subnets, internet gateways, route tables) using a reusable module pattern with isolated state files stored in an encrypted S3 bucket.
By the end of this project, you'll have:
- A reusable VPC module with configurable inputs and typed outputs that provisions networking infrastructure.
- A remote state backend using S3 versioning, encryption, and DynamoDB locking to prevent concurrent modifications.
- Two isolated environment deployments (dev and prod) using the same module with different parameters.
- Secret Mission: Add a compute module that launches a t2.micro EC2 instance with a security group into your VPC.
Are there any prerequisites?
You will need an AWS account with free tier access, the AWS CLI installed and configured, VS Code, and a GitHub account. If you do not have these set up yet, Step 1 will guide you through verifying your tools.
Before We Start
To build a multi-environment network on AWS, you need a tool that can define your infrastructure in code and deploy it consistently across environments.
That tool is Terraform. In this step, you will install Terraform, add the Terraform extension to VS Code, and confirm that your AWS credentials are working.
In this step, get ready to:
- Install Terraform on your machine.
- Install the HashiCorp Terraform extension in VS Code.
- Verify your AWS CLI credentials.
Install Terraform on Windows
Terraform is a single executable file that you download, place in a folder, and add to your system PATH so you can run it from any terminal.
- Check if Terraform is already installed by opening a terminal and running this command:
terraform version
✔️ I see version 1.15 or higher
You already have a compatible version of Terraform installed. You can skip ahead to the next substep.
ⓧ I see an older version
You have Terraform installed, but it is an older version. You need version 1.15.0 or higher for this project. Follow these steps to upgrade.
- Download the Terraform 1.15.6 Windows AMD64 binary from the HashiCorp releases page:
- https://releases.hashicorp.com/terraform/1.15.6/terraform_1.15.6_windows_amd64.zip
- Extract the zip file.
- Replace your existing terraform.exe with the new one from the zip.
- Close and reopen your terminal.
- Run terraform version again to confirm you see 1.15.6.
ⓧ Command not found
Terraform is not installed yet. Follow these steps to install it.
- Download the Terraform 1.15.6 Windows AMD64 binary from the HashiCorp releases page:
- https://releases.hashicorp.com/terraform/1.15.6/terraform_1.15.6_windows_amd64.zip
- Extract the zip file. You will see a single file called terraform.exe.
- Create a permanent folder for the binary. A good location is C:\terraform.
- Move terraform.exe into C:\terraform.
- Now add that folder to your system PATH. Press the Windows key, type Environment Variables, and press Enter.
- In the System Properties window, click Environment Variables.
- Under System variables, find and select Path, then click Edit.
- Click New and type C:\terraform.
- Click OK on all open windows to save.
- Close and reopen your terminal. This is required for the PATH change to take effect.
- Verify the installation by running:
terraform version
You should see output showing Terraform v1.15.6.
What is Terraform?
Terraform is an infrastructure as code tool that lets you define cloud resources in configuration files instead of clicking through web consoles. You write what you want, and Terraform figures out how to create it.
The terraform command is the CLI you will use throughout this project to initialize configurations, preview changes, and apply them to AWS.
Still seeing 'command not found' after adding to PATH?
Make sure you closed and reopened your terminal after editing the PATH. Existing terminal sessions will not pick up the change.
Double check that the C:\terraform folder actually contains terraform.exe. If you extracted to a subfolder, the executable might be in C:\terraform\terraform_1.15.6_windows_amd64\terraform.exe instead.
help me fix a PATH issue on Windows for Terraform
Install the HashiCorp Terraform extension in VS Code
The HashiCorp Terraform extension gives you syntax highlighting, auto-completion, and format-on-save for .tf files. This makes writing Terraform configurations much faster.
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
- Open the Extensions view by pressing Ctrl+Shift+X.
- In the search box, type hashicorp.terraform.
- Click Install on the extension by HashiCorp (the publisher name will say "HashiCorp").
After installation, the Install button changes to a gear icon. This confirms the extension is active.
Not finding the extension?
Make sure you type the exact extension ID hashicorp.terraform in the search box. If you search by name alone, several similar extensions may appear. The correct one is published by HashiCorp.
help me find and install the HashiCorp Terraform extension in VS Code
Verify your AWS CLI credentials
Terraform uses the same credentials as the AWS CLI to authenticate with your AWS account. Before you write any Terraform code, you need to confirm that the CLI can reach AWS.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Verify your credentials by running this command:
aws sts get-caller-identity
What should I see?
You should see a JSON response containing three fields: your AWS account ID, a user ARN, and a user ID. This confirms that your CLI can authenticate to AWS successfully.
The exact values will be specific to your AWS account. As long as you see output (and not an error), you are good to go.
Seeing an error about credentials?
If you see a message about being unable to locate credentials, your AWS CLI is not configured. Run aws configure and enter your Access Key ID, Secret Access Key, default region (us-east-1), and output format (json).
help me fix my AWS CLI credentials configuration
Your development environment is ready. Next up, you will bootstrap the remote state backend that keeps your Terraform state files safe and locked.
Bootstrap the Remote State Backend
Your environment is ready. Now it is time to give Terraform a safe place to store its knowledge of what infrastructure it manages.
Terraform tracks every resource it creates in a "state" file. By default, state lives on your local machine. That is fine for experimenting, but in production you need state stored remotely so it is backed up, encrypted, and protected from two people writing to it at the same time. In this step, you will create an S3 bucket for storing state and a DynamoDB table for locking.
In this step, get ready to:
- Create the bootstrap Terraform configuration files.
- Deploy the S3 state bucket and DynamoDB lock table to AWS.
- Verify both resources exist in the AWS Console.
Create and deploy the bootstrap configuration
Why does bootstrap use local state?
The S3 bucket and DynamoDB table must exist before any other configuration can use them as a backend. This creates a chicken-and-egg problem: you cannot store state in a bucket that does not exist yet.
The solution is the "bootstrap" pattern. This directory uses Terraform's default local state to create the backend resources. Every other directory in the project then points its backend at these resources. This pattern is standard in production and is an interview talking point itself.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the bootstrap subdirectory by running these commands:
mkdir terraform-multi-env
cd terraform-multi-env
mkdir bootstrap
cd bootstrap
- Confirm the folder exists by running:
ls
You should see an empty directory. This is where you will create your first Terraform files.
- Open the terraform-multi-env project folder in VS Code.
- Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
- Type File: Open Folder and press Enter.
- Navigate to your Desktop and select the terraform-multi-env folder, then click Open.
- In the Explorer sidebar, right-click the bootstrap folder and select New File.
- Name it variables.tf and paste the following code:
variable "aws_region" {
description = "AWS region for the state backend"
type = string
default = "us-east-1"
}
variable "state_bucket_name" {
description = "Globally unique name for the S3 state bucket"
type = string
}
variable "lock_table_name" {
description = "Name of the DynamoDB table for state locking"
type = string
default = "terraform-state-locks"
}
What does this code do?
- Each variable block defines an input that other files can reference using var.<name>.
- The type field enforces that only strings are accepted, catching typos early.
- Variables with a default value are optional at runtime. The state_bucket_name variable has no default, so Terraform will require you to provide one.
- Create another new file in the bootstrap folder named terraform.tfvars and paste the following code:
aws_region = "us-east-1"
state_bucket_name = "[[STATE_BUCKET_NAME=\"your-unique-terraform-state-bucket-name\"]]"
lock_table_name = "terraform-state-locks"
What does this file do?
The .tfvars file provides the actual values for your variables. Terraform automatically loads any file named terraform.tfvars when you run commands.
S3 bucket names must be globally unique across all AWS accounts. Replace the placeholder with something personal like yourname-terraform-state-2026. Use only lowercase letters, numbers, and hyphens.
- Create one more file in the bootstrap folder named main.tf. This is the core configuration file. Start by pasting the Terraform settings, provider, and S3 bucket resource:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "terraform_state" {
bucket = var.state_bucket_name
tags = {
Name = "Terraform State"
Environment = "global"
ManagedBy = "terraform"
}
}
What does this code do?
- The terraform block pins the minimum Terraform version and declares the AWS provider from the HashiCorp registry.
- The provider block tells Terraform to use AWS in the region you specified.
- The aws_s3_bucket resource creates the bucket that will hold your state files. Tags help you identify this bucket later in the AWS Console.
- Still in main.tf, paste the following security configuration below the S3 bucket resource:
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
What does this code do?
- Versioning keeps a history of every state file change. If state gets corrupted, you can roll back to a previous version.
- Server-side encryption ensures state data is encrypted at rest using AES256.
- The public access block prevents anyone from accidentally making your state bucket public. State files can contain sensitive values, so this is critical.
- Still in main.tf, paste the DynamoDB table resource below the public access block:
resource "aws_dynamodb_table" "terraform_locks" {
name = var.lock_table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform Lock Table"
Environment = "global"
ManagedBy = "terraform"
}
}
What does this code do?
This creates a DynamoDB table that acts as a lock. When someone runs terraform apply, Terraform writes a lock entry to this table. If a second person tries to apply at the same time, they will see a lock error and have to wait. This prevents conflicting changes from corrupting your state.
The PAY_PER_REQUEST billing mode means you only pay for actual read/write operations. For a state lock table that gets a few operations per deploy, this costs effectively nothing.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in the bootstrap folder.
ⓧ I'd like to double check the full code
Here is the complete main.tf file for comparison:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "terraform_state" {
bucket = var.state_bucket_name
tags = {
Name = "Terraform State"
Environment = "global"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "terraform_locks" {
name = var.lock_table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform Lock Table"
Environment = "global"
ManagedBy = "terraform"
}
}
- Save all three files: variables.tf, terraform.tfvars, and main.tf.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key). Make sure you are inside the bootstrap folder.
- Initialize Terraform by running this command:
terraform init
You should see output ending with Terraform has been successfully initialized! and a message about the AWS provider being installed.
Seeing an error during init?
- If you see a version constraint error, confirm your terraform version is 1.15.0 or higher.
- If you see a provider download error, check your internet connection. Terraform needs to download the AWS provider from the registry.
- Make sure your terminal is inside the bootstrap folder where main.tf lives. Run cd ~/Desktop/terraform-multi-env/bootstrap to navigate there.
Ask for help: Why is terraform init failing in my bootstrap directory?
- Deploy the resources to AWS by running:
terraform apply
What happens when you run terraform apply?
Terraform shows you an execution plan listing every resource it will create. It then asks for confirmation. Type yes and press Enter to proceed.
After a few seconds, you should see a success message like Apply complete! Resources: 5 added, 0 changed, 0 destroyed. confirming your S3 bucket and DynamoDB table now exist in AWS.
Seeing a BucketAlreadyExists error?
S3 bucket names are globally unique across all AWS accounts. If your chosen name is already taken, open terraform.tfvars and change state_bucket_name to something more unique. Add your name and today's date to make it unlikely to collide.
Ask for help: My terraform apply failed with a BucketAlreadyExists error. How do I fix it?
Verify your resources in the AWS Console
- Open your browser and navigate to the AWS Management Console.
- In the search bar at the top, type S3 and select S3 from the results.
- Find your state bucket in the list and click its name.
- Click the Properties tab.
- Scroll down to the Bucket Versioning section. Confirm it shows Enabled.
- Now search for DynamoDB in the AWS Console search bar and select DynamoDB.
- Click Tables in the left sidebar.
- Confirm you see a table named terraform-state-locks.
- Click the table name, then check the General information section. You should see LockID listed as the partition key with type String.
Why verify in the console?
Terraform told you it created resources, but verifying in the console proves they actually exist in your AWS account with the right settings. This habit catches configuration drift and mismatched assumptions early.
Your remote state backend is live. Next up, you will build a reusable VPC module and deploy your first environment using this backend to store its state.
Create the VPC Module
Your state backend is ready to store infrastructure state securely. Now you need the actual infrastructure code to manage.
In this step, you will build a reusable VPC module that provisions networking resources (VPC, subnets, internet gateway, route tables) and then deploy it to a dev environment. The module pattern means you write the networking logic once and reuse it across as many environments as you need.
In this step, get ready to:
- Create a reusable VPC module with typed inputs and outputs.
- Configure a dev environment that calls the module and stores state in S3.
- Deploy the dev VPC and verify it in the AWS Console.
Build the VPC module files
A Terraform module is a self-contained directory of .tf files that encapsulates a set of related resources. Your VPC module will live in modules/vpc/ and contain three files: inputs, resources, and outputs.
- Create the module directory by running these commands in your terminal:
mkdir -p modules/vpc
- Create the variables.tf file inside modules/vpc/ by opening it in VS Code.
- Paste the following code into modules/vpc/variables.tf:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "availability_zones" {
description = "Availability zones for subnets"
type = list(string)
}
variable "project_name" {
description = "Name of the project for resource naming"
type = string
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
}
What does this code do?
Each variable block declares a typed input that callers must provide when using this module.
- vpc_cidr defines the IP address range for the entire VPC (e.g. 10.0.0.0/16).
- public_subnet_cidrs is a list so you can create multiple subnets dynamically using count.
- project_name and environment are used in resource tags and naming conventions.
- Save modules/vpc/variables.tf.
- Now create modules/vpc/main.tf and paste the VPC and subnet resources:
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${var.environment}-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
What does this code do?
The aws_vpc resource creates your Virtual Private Cloud with DNS support enabled so instances can resolve hostnames.
The aws_subnet resource uses count = length(var.public_subnet_cidrs) to dynamically create one subnet per CIDR in the list. This means passing two CIDRs creates two subnets automatically, without duplicating code.
Setting map_public_ip_on_launch = true ensures any instance launched in these subnets gets a public IP address.
- Still in modules/vpc/main.tf, add the routing infrastructure below the subnet resource:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw-${var.environment}"
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-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
What does this code do?
The internet gateway allows traffic to flow between your VPC and the public internet. Without it, nothing inside the VPC can reach the outside world.
The route table defines a default route (0.0.0.0/0) pointing all outbound traffic to the internet gateway. The route table association links each public subnet to this route table using the same count pattern as the subnets themselves.
- Save modules/vpc/main.tf.
✔️ Awesome, I've got everything!
Great. Make sure you have saved modules/vpc/main.tf with all five resources.
ⓧ I'd like to double check the full code
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${var.environment}-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw-${var.environment}"
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-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
- Create modules/vpc/outputs.tf and paste the following code:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "vpc_cidr" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = aws_internet_gateway.main.id
}
What does this code do?
Outputs expose values from the module so that calling code can reference them. For example, module.vpc.vpc_id will let your environment config read the VPC ID after creation.
The splat expression aws_subnet.public[*].id collects all subnet IDs into a single list, regardless of how many subnets were created by count.
- Save modules/vpc/outputs.tf.
- Confirm your module directory has all three files by running:
ls modules/vpc/
You should see main.tf, outputs.tf, and variables.tf listed.
Missing a file?
If ls shows fewer than three files, check that you created all files inside modules/vpc/ and not in the project root. In VS Code, look at the Explorer sidebar to confirm the file tree shows modules > vpc > main.tf, variables.tf, outputs.tf.
Help me find where my module files ended up.
Set up the dev environment configuration
A module defines reusable logic, but it does not run on its own. You need an environment directory that calls the module, configures the backend, and supplies environment-specific values.
- Create the dev environment directory by running:
mkdir -p environments/dev
- Create environments/dev/backend.tf and paste the following code:
terraform {
backend "s3" {
bucket = "your-unique-terraform-state-bucket-name"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
What does this code do?
This tells Terraform to store the dev environment's state file in the S3 bucket you created in the bootstrap step. The key value dev/terraform.tfstate means the state lives under a dev/ prefix, keeping it separate from other environments.
The dynamodb_table setting enables state locking. If two people run terraform apply at the same time, DynamoDB prevents them from corrupting the state file by allowing only one lock at a time.
Use your own bucket name
Replace your-unique-terraform-state-bucket-name with the actual bucket name you used in the bootstrap step. The name must match exactly or Terraform will fail to configure the backend.
- Save environments/dev/backend.tf.
- Create environments/dev/main.tf and paste the following code:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
public_subnet_cidrs = var.public_subnet_cidrs
availability_zones = var.availability_zones
project_name = var.project_name
environment = var.environment
}
What does this code do?
The default_tags block automatically applies Project, Environment, and ManagedBy tags to every resource created by this provider. This eliminates repetitive tag blocks across your configuration.
The module "vpc" block calls your reusable module. The source path points to the module directory relative to this file. Each argument maps to a variable you defined in modules/vpc/variables.tf.
- Save environments/dev/main.tf.
- Create environments/dev/variables.tf and paste the following code:
variable "aws_region" {
description = "AWS region"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "availability_zones" {
description = "Availability zones for subnets"
type = list(string)
}
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
Why declare variables again here?
The environment directory is its own Terraform root module. It needs its own variable declarations so it can accept values from terraform.tfvars and pass them down to child modules.
- Save environments/dev/variables.tf.
- Create environments/dev/terraform.tfvars and paste the dev-specific values:
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
project_name = "devops-project"
environment = "dev"
What does this code do?
This file supplies concrete values for the dev environment. The VPC gets a 10.0.0.0/16 CIDR (65,536 IP addresses), and two public subnets are placed in separate availability zones for resilience.
When you create the prod environment later, you will use a different CIDR (10.1.0.0/16) to keep the address spaces completely isolated.
- Save environments/dev/terraform.tfvars.
- Create environments/dev/outputs.tf and paste the following code:
output "vpc_id" {
description = "ID of the VPC"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = module.vpc.public_subnet_ids
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = module.vpc.internet_gateway_id
}
What does this code do?
These outputs surface the module's return values at the root level. After terraform apply runs, you can retrieve the VPC ID and subnet IDs with terraform output without opening the AWS Console.
- Save environments/dev/outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check that your environments/dev/ folder contains all five files: backend.tf, main.tf, variables.tf, terraform.tfvars, and outputs.tf.
ⓧ I'd like to double check the full code
Here is each file in environments/dev/ for comparison:
terraform {
backend "s3" {
bucket = "your-unique-terraform-state-bucket-name"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
public_subnet_cidrs = var.public_subnet_cidrs
availability_zones = var.availability_zones
project_name = var.project_name
environment = var.environment
}
variable "aws_region" {
description = "AWS region"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "availability_zones" {
description = "Availability zones for subnets"
type = list(string)
}
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
project_name = "devops-project"
environment = "dev"
output "vpc_id" {
description = "ID of the VPC"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = module.vpc.public_subnet_ids
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = module.vpc.internet_gateway_id
}
Deploy the dev environment
With all the configuration files in place, you can now initialize Terraform, preview the changes, and apply them to create real AWS resources.
- Navigate into the dev environment directory by running:
cd environments/dev
- Initialize Terraform and configure the S3 backend by running:
terraform init
You should see a message that says Successfully configured the backend "s3"! followed by Terraform has been successfully initialized!.
Seeing a backend configuration error?
If Terraform reports it cannot access the S3 bucket, check that the bucket value in backend.tf matches your actual bucket name character-for-character. Also confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity in a separate terminal.
Help me debug my terraform init backend error.
- Preview the infrastructure Terraform will create by running:
terraform plan
Terraform displays a list of resources it will create. You should see 7 resources planned: 1 VPC, 2 subnets, 1 internet gateway, 1 route table, and 2 route table associations.
- Apply the configuration to create the resources by running:
terraform apply
- Terraform will ask you to confirm. Type yes and press Enter.
After a moment, you should see Apply complete! Resources: 7 added, 0 changed, 0 destroyed. along with your output values (VPC ID, subnet IDs, internet gateway ID).
Apply failed with an error?
If you see an error about invalid CIDR blocks, double check that terraform.tfvars uses the exact values 10.0.0.0/16, 10.0.1.0/24, and 10.0.2.0/24.
If the error mentions a module source path, verify that source = "../../modules/vpc" in main.tf correctly points to your module directory. The path is relative to environments/dev/.
Help me fix my terraform apply error.
- Open the AWS Console and navigate to VPC (search for VPC in the top search bar).
- Click Your VPCs in the left sidebar.
- You should see a VPC named devops-project-vpc-dev with CIDR 10.0.0.0/16.
- Click Subnets in the left sidebar to confirm two subnets (devops-project-public-dev-1 and devops-project-public-dev-2) exist.
Your dev VPC is live and your state is stored remotely in S3. Next up, you will deploy the same module to a prod environment with a different CIDR, proving that your module is truly reusable.
Deploy the Prod Environment
Your dev environment is live and its state is safely stored in S3. Now you'll prove the real power of modules: deploying the exact same VPC module to a separate production environment with different parameters.
Because each environment has its own directory and its own state file, dev and prod are completely isolated. A change to one can never accidentally affect the other.
In this step, get ready to:
- Create the prod environment directory with production-specific network values.
- Deploy the prod VPC using the same reusable module.
- Verify state isolation between dev and prod.
Create the prod environment directory
The prod directory mirrors the dev structure exactly. The only differences are the CIDR ranges (to avoid IP conflicts) and the state file key (so each environment gets its own state).
- Create the prod directory and navigate into it by running these commands:
mkdir ../prod
cd ../prod
Why a separate directory instead of a workspace?
Terraform workspaces share the same configuration files and only switch state. Separate directories give you independent backend configs, independent variable files, and zero risk of accidentally applying dev values to prod.
This directory-based pattern is what most production teams use for long-lived environments like dev and prod.
- Create backend.tf by pasting this configuration:
terraform {
backend "s3" {
bucket = "your-unique-terraform-state-bucket-name"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
What does this code do?
This backend block points to the same S3 bucket and DynamoDB lock table as dev. The critical difference is the key value: prod/terraform.tfstate instead of dev/terraform.tfstate.
This means prod's state is stored in a completely separate file within the same bucket. The DynamoDB lock table prevents two people from modifying the same state file at the same time.
- Create main.tf with the provider configuration and module call:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
public_subnet_cidrs = var.public_subnet_cidrs
availability_zones = var.availability_zones
project_name = var.project_name
environment = var.environment
}
What does this code do?
This is identical to dev's main.tf. The default_tags block automatically applies Project, Environment, and ManagedBy tags to every resource Terraform creates.
The module "vpc" block calls the same reusable module from ../../modules/vpc. The actual values (CIDR, subnets, environment name) come from terraform.tfvars, which is where dev and prod diverge.
- Create variables.tf with the variable declarations:
variable "aws_region" {
description = "AWS region"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "availability_zones" {
description = "Availability zones for subnets"
type = list(string)
}
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
What does this code do?
These variable declarations are identical to dev. They define the shape of the inputs (types and descriptions) without assigning values. The actual values live in terraform.tfvars.
- Create terraform.tfvars with the prod-specific values:
aws_region = "us-east-1"
vpc_cidr = "10.1.0.0/16"
public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
project_name = "devops-project"
environment = "prod"
What does this code do?
This is where prod differs from dev. The VPC CIDR is 10.1.0.0/16 (dev uses 10.0.0.0/16). The subnets follow the same pattern but in the 10.1.x.x range.
Using different CIDR blocks means the two VPCs could be peered together in the future without IP address conflicts. This is a production best practice.
- Create outputs.tf to expose the module's outputs:
output "vpc_id" {
description = "ID of the VPC"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = module.vpc.public_subnet_ids
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = module.vpc.internet_gateway_id
}
What does this code do?
These outputs surface the VPC ID, subnet IDs, and internet gateway ID after deployment. They are identical to dev's outputs because both environments produce the same resource types.
- Save all five files in the environments/prod/ directory.
- Confirm the files exist by running:
ls
You should see backend.tf, main.tf, outputs.tf, terraform.tfvars, and variables.tf in the output.
Missing a file?
Make sure you created all five files inside environments/prod/ and not in the project root or the dev directory. Check your terminal's current directory with pwd to confirm you are in the prod folder.
If you see dev files instead, you are in the wrong directory. Help me find my prod directory
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all five files in environments/prod/:
terraform {
backend "s3" {
bucket = "your-unique-terraform-state-bucket-name"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr
public_subnet_cidrs = var.public_subnet_cidrs
availability_zones = var.availability_zones
project_name = var.project_name
environment = var.environment
}
variable "aws_region" {
description = "AWS region"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
}
variable "availability_zones" {
description = "Availability zones for subnets"
type = list(string)
}
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
aws_region = "us-east-1"
vpc_cidr = "10.1.0.0/16"
public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
project_name = "devops-project"
environment = "prod"
output "vpc_id" {
description = "ID of the VPC"
value = module.vpc.vpc_id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = module.vpc.public_subnet_ids
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = module.vpc.internet_gateway_id
}
Deploy the prod environment
With the configuration in place, you can now initialize and apply prod. Terraform will download the AWS provider, configure the S3 backend, and create your prod VPC resources.
- Initialize the prod environment by running:
terraform init
What does this command do?
This downloads the AWS provider, configures the S3 backend with the prod/terraform.tfstate key, and creates the .terraform directory with the provider binaries.
You should see a message that the backend was successfully configured and Terraform has been initialized.
- Deploy the prod VPC by running:
terraform apply
- Terraform displays a plan showing the resources it will create. Review the plan and type yes when prompted to confirm.
What should I see?
The plan shows 5 resources to add: a VPC, two subnets, an internet gateway, a route table, and two route table associations. After you confirm, Terraform creates everything and displays the outputs (VPC ID, subnet IDs, internet gateway ID).
- Verify the prod VPC in the AWS Console. Open the VPC service and look for a VPC named devops-project-vpc-prod with CIDR 10.1.0.0/16.
- Confirm you now see two VPCs: devops-project-vpc-dev and devops-project-vpc-prod.
Why two separate VPCs?
Each environment gets its own isolated network. A misconfigured security group in dev can never accidentally expose prod resources. This is how teams protect production workloads while keeping dev flexible for experimentation.
- Verify both state files exist in S3. In the AWS Console, open your state bucket and confirm you see both dev/terraform.tfstate and prod/terraform.tfstate.
Seeing an error during terraform apply?
If you see a backend configuration error, make sure backend.tf uses the exact bucket name you chose during the bootstrap step. The bucket name must match what you used in bootstrap/terraform.tfvars.
If you see a credentials error, run aws sts get-caller-identity to confirm your CLI session is still active.
Help me debug my Terraform apply error
Demonstrate state isolation
The whole point of separate directories is that each environment's state is completely independent. You can prove this by listing what Terraform knows about in each environment.
- List the resources Terraform manages in prod by running:
terraform state list
What should I see?
You should see resources prefixed with module.vpc, like:
- module.vpc.aws_vpc.main
- module.vpc.aws_subnet.public[0]
- module.vpc.aws_subnet.public[1]
- module.vpc.aws_internet_gateway.main
- module.vpc.aws_route_table.public
- module.vpc.aws_route_table_association.public[0]
- module.vpc.aws_route_table_association.public[1]
These are prod's resources only. Terraform has no knowledge of dev's resources from this directory.
- Switch to the dev directory by running:
cd ../dev
- List the dev resources by running:
terraform state list
You see the exact same resource types, but these are dev's resources. Terraform in this directory has no idea the prod VPC exists.
Why does this matter?
State isolation means running terraform destroy in dev will never touch prod. Each environment is a self-contained unit that can be managed, updated, or torn down independently.
In a team setting, this also means different people can work on dev and prod simultaneously without state lock conflicts.
- Navigate back to the prod directory for the next step by running:
cd ../prod
Both environments are deployed and completely isolated. Next up, you'll validate the infrastructure and explore how Terraform detects drift between your configuration and the real world.
Validate and Inspect
Both your dev and prod environments are live, each with their own isolated state files stored in S3.
In this final step, you will query Terraform's outputs to verify your infrastructure matches what you expect. You will also test Terraform's drift detection by running a plan with no changes, and then simulate an incremental change to see how Terraform proposes updates.
In this step, get ready to:
- Run terraform output in each environment and cross-reference the results with the AWS Console.
- Confirm zero drift with terraform plan and simulate an incremental infrastructure change.
Inspect outputs in dev and prod
Terraform outputs let you extract key resource identifiers (like VPC IDs and subnet IDs) without digging through the AWS Console. This is how teams share infrastructure details between modules and between humans.
- Navigate to your dev environment directory by running this command:
cd environments/dev
- Display the dev environment outputs by running:
terraform output
You should see three outputs printed to your terminal: vpc_id, public_subnet_ids, and internet_gateway_id.
What do these outputs mean?
Each output is a real AWS resource ID that Terraform extracted after creating your infrastructure. The vpc_id identifies your Virtual Private Cloud, public_subnet_ids lists the subnets inside it, and internet_gateway_id points to the gateway that connects your VPC to the internet.
In a real team environment, other modules (like a compute module) consume these outputs to place resources inside your network.
- Now navigate to the prod environment by running:
cd ../prod
- Display the prod environment outputs by running:
terraform output
You should see a different set of resource IDs compared to dev. This confirms that dev and prod are completely separate VPCs, even though they were created from the same module.
- Open the AWS Console in your browser.
- Navigate to the VPC service.
- Confirm you can see both VPCs listed: devops-project-vpc-dev and devops-project-vpc-prod.
- Compare the VPC IDs shown in the console with the vpc_id values from your terraform output commands.
Quick cross-reference
Copy a vpc_id from your terminal output and paste it into the AWS Console's VPC search bar. If it finds the matching VPC, your Terraform state is accurate.
Seeing empty outputs?
Make sure you are inside the correct environment directory (environments/dev or environments/prod) when running terraform output. Running it from the project root or from the wrong directory returns nothing because Terraform reads the local backend configuration.
If outputs are still empty, try running terraform init first to reconnect to the S3 backend. Help me troubleshoot empty terraform output
Confirm zero drift and test incremental changes
Drift detection is one of Terraform's most powerful features. When you run terraform plan on infrastructure that hasn't changed, Terraform confirms everything is in sync.
- Navigate back to the dev environment by running:
cd ../dev
- Run a plan with no pending changes by running:
terraform plan
You should see the message: No changes. Your infrastructure matches the configuration. This confirms zero drift between your state file and what actually exists in AWS.
Why does zero drift matter?
In production, someone might manually edit a resource through the AWS Console. Running terraform plan detects those manual changes and shows you exactly what drifted.
A clean "no changes" plan is proof that your infrastructure matches your code. This is the foundation of Infrastructure as Code reliability.
Now let's see what happens when you propose a change. You will temporarily add a third subnet to the dev configuration to observe how Terraform plans an incremental update.
- In VS Code, open environments/dev/terraform.tfvars.
- Find these two lines:
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
- Add a third entry to each list so they look like this:
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
- Save terraform.tfvars.
- Run the plan again to see how Terraform proposes the change:
terraform plan
This time, Terraform shows you exactly what it wants to add. You should see a plan summary like Plan: 2 to add, 0 to change, 0 to destroy. indicating a new subnet and a new route table association.
What does this incremental plan demonstrate?
Terraform only proposes changes for what's different. Your existing VPC, subnets, internet gateway, and route table remain untouched.
This is the core value of terraform plan in production workflows. Teams review the plan output in pull requests before running terraform apply, catching unintended changes before they reach live infrastructure.
Now revert the change so your infrastructure stays clean.
- In VS Code, open environments/dev/terraform.tfvars again.
- Change the two lines back to their original values:
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
- Save terraform.tfvars.
- Confirm the revert worked by running:
terraform plan
You should see No changes. Your infrastructure matches the configuration. again, proving your config is back in sync.
Still seeing planned changes after reverting?
Double-check that terraform.tfvars has exactly two entries in each list, not three. Make sure you saved the file after editing.
If the plan shows changes to tags or other attributes you did not edit, someone may have modified the resources manually in the AWS Console. Help me understand why terraform plan shows unexpected changes
✔️ Awesome, I've got everything!
Great. Double check you have saved environments/dev/terraform.tfvars with its original two subnets.
ⓧ I'd like to double check the full code
Here is what environments/dev/terraform.tfvars should look like after reverting:
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
project_name = "devops-project"
environment = "dev"
Secret mission
Add a Compute Module
Your VPC is a network without any servers. In this challenge, you'll create a second Terraform module that launches an EC2 instance into your dev VPC, complete with a security group and cross-module references.
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 VPCs, S3 bucket, and DynamoDB table in this project all fall within the AWS free tier, so there are no ongoing costs.
Resources you used:
- S3 bucket for Terraform state storage (versioned, encrypted).
- DynamoDB table for state locking.
- Dev VPC with subnets, internet gateway, and route tables.
- Prod VPC with subnets, internet gateway, and route tables.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with Terraform or want to reference this project later.
- Your VPCs, S3 bucket, and DynamoDB table cost $0 under normal usage.
- Your local project files remain on your Desktop for future reference.
- Terraform remains installed on your system at C:\terraform.
Pause - I'll come back to this later
Remove the VPCs to keep your AWS account tidy, but keep the state backend for future Terraform projects.
- Open your terminal and navigate to the environments/prod directory inside your project folder on the Desktop.
- Run the following command to destroy all prod resources:
terraform destroy
- Type yes when prompted to confirm.
- Navigate to the environments/dev directory and run the same command:
terraform destroy
- Type yes when prompted to confirm.
- Your S3 bucket and DynamoDB table remain intact for future projects.
- Your local project files stay on your Desktop.
Delete - I don't want to use this again
Remove all AWS resources and local project files. Follow these steps in order.
- Open your terminal and navigate to the environments/prod directory inside your project folder on the Desktop.
- Destroy the prod environment by running:
terraform destroy
- Type yes when prompted.
- Navigate to the environments/dev directory and destroy the dev environment:
terraform destroy
- Type yes when prompted.
- Before destroying the bootstrap, you need to empty the S3 bucket. The bucket has versioning enabled, so the easiest method is through the AWS Console.
- Open the S3 console in your browser.
- Click on your state bucket name.
- Click the Empty button.
- Type permanently delete in the confirmation field and click Empty.
- Return to your terminal and navigate to the bootstrap directory.
- Destroy the state backend resources by running:
terraform destroy
- Type yes when prompted. This removes the S3 bucket and DynamoDB table.
- Finally, delete your local project files by running:
cd ~/Desktop
rm -rf terraform-multi-env
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force terraform-multi-env
- Your AWS account and local machine are now clean of all project resources.
Nice Work!
Nice Work!
You just built a production-grade, multi-environment AWS network using Terraform. This is the exact project structure that infrastructure teams use in real companies to manage cloud resources at scale.
You've learned how to:
- Create a reusable VPC module with typed inputs, outputs, and consistent tagging, then deploy it to isolated dev and prod environments with different parameters.
- Set up a remote state backend with S3 versioning, AES256 encryption, and DynamoDB locking to prevent concurrent modifications.
- Demonstrate state isolation and drift detection across environments using terraform state list and terraform plan.
- Secret Mission: Built a compute module with AMI data sources, security groups, and cross-module references to launch an EC2 instance into your VPC.
Ready to quiz yourself?