Sample — Medium project environment
This page shows a medium project layout: separate directories per environment (dev, staging, prod) that share the same modules. Each environment has its own state, backend config, and variable values.
Typical exercise scope: “Spin up an AWS EC2 instance with an attached security group, in a non-default VPC, in us-east-1.” This sample supports that: the vpc module creates a dedicated (non-default) VPC and subnets; the app module creates an EC2 instance and security group in that VPC. Region is set via variables (e.g. us-east-1 in terraform.tfvars). No hardcoded region or CIDRs in resource blocks — all parameterized so you can judge modularization, variable use, and reuse across environments.
Directory layout
Section titled “Directory layout”terraform/ modules/ vpc/ main.tf variables.tf outputs.tf app/ main.tf variables.tf outputs.tf environments/ dev/ main.tf variables.tf terraform.tfvars backend.tf staging/ ... prod/ ...Example: Dev environment
Section titled “Example: Dev environment”Typical contents for one environment (e.g. environments/dev/).
environments/dev/main.tf
Section titled “environments/dev/main.tf”Provider and module calls; pass env-specific variables into shared modules.
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }}
provider "aws" { region = var.aws_region}
module "vpc" { source = "../../modules/vpc"
vpc_cidr = var.vpc_cidr environment = var.environment public_subnets = var.public_subnets}
module "app" { source = "../../modules/app"
environment = var.environment vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.public_subnet_ids instance_type = var.instance_type}environments/dev/variables.tf
Section titled “environments/dev/variables.tf”Root module inputs. Each environment can use the same variable definitions and only change .tfvars.
variable "environment" { description = "Environment name (dev, staging, prod)" type = string}
variable "aws_region" { description = "AWS region" type = string}
variable "vpc_cidr" { description = "CIDR for the VPC" type = string}
variable "public_subnets" { description = "List of public subnet CIDRs" type = list(string)}
variable "instance_type" { description = "EC2 instance type for app servers" type = string}environments/dev/terraform.tfvars
Section titled “environments/dev/terraform.tfvars”Dev-specific values. Do not put secrets here; use environment variables or a secret store for sensitive values.
environment = "dev"aws_region = "us-east-1"vpc_cidr = "10.0.0.0/16"public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]instance_type = "t3.micro"environments/dev/backend.tf
Section titled “environments/dev/backend.tf”Remote state so each environment has its own state file. Use a different key per environment (e.g. environments/prod/terraform.tfstate for prod).
terraform { backend "s3" { bucket = "my-company-terraform-state" key = "environments/dev/terraform.tfstate" region = "us-east-1" encrypt = true dynamodb_table = "terraform-state-lock" }}Running Terraform
Section titled “Running Terraform”From environments/dev/ run:
terraform initterraform planterraform applyStaging and prod use the same structure under environments/staging/ and environments/prod/ with different terraform.tfvars and backend key (e.g. environments/prod/terraform.tfstate).
What the app module typically creates
Section titled “What the app module typically creates”The app module is expected to create at least an EC2 instance and a security group attached to it, in the VPC and subnets provided by the vpc module. Example shape (actual implementation lives in modules/app/):
# modules/app/main.tf (conceptual)resource "aws_security_group" "app" { name_prefix = "${var.environment}-app-" vpc_id = var.vpc_id ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.ingress_cidr] # variable, not 0.0.0.0/0 } egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type subnet_id = var.subnet_ids[0] vpc_security_group_ids = [aws_security_group.app.id] tags = { Name = "${var.environment}-app", Environment = var.environment }}Using a variable for ingress CIDR (e.g. var.ingress_cidr or a list of allowed prefixes) instead of hardcoding 0.0.0.0/0 avoids overly permissive security groups — a common review point. For more on tooling that flags this, see Best Practices — Testing and policy.