How to Use Terraform to Provision Infrastructure on RHEL 7
Once Terraform is installed, the real power comes from writing complete, production-grade infrastructure configurations. A single Terraform project can define an entire cloud environment — VPC networking, subnets, security groups, EC2 instances, load balancers, and databases — all in version-controlled code that can be reviewed, tested, and deployed consistently. This guide builds on a basic Terraform installation and walks through writing a complete AWS environment, using modules and data sources, configuring a remote S3 backend for shared team state, managing multiple environments with workspaces, importing existing resources, and enforcing quality with terraform fmt and terraform validate. All commands are run from a RHEL 7 workstation.
Prerequisites
- Terraform installed on RHEL 7 (see the installation guide)
- AWS CLI configured with credentials that have permission to create VPCs, EC2 instances, and security groups
- An S3 bucket and DynamoDB table for remote state (created beforehand or using a bootstrap script)
- Terraform version 1.0 or later (
terraform --versionto confirm) - A text editor —
vimornanoare available on RHEL 7 by default
Step 1: Writing a Complete Environment — VPC, EC2, and Security Groups
A production-grade environment separates configuration into logical files. Create a project directory and structure it as follows:
mkdir -p ~/terraform/aws-env
cd ~/terraform/aws-env
Create the VPC and networking configuration:
cat > vpc.tf <<'EOF'
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project}-${var.environment}-vpc"
Environment = var.environment
Project = var.project
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project}-${var.environment}-igw"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project}-${var.environment}-public-${count.index + 1}"
}
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.project}-${var.environment}-private-${count.index + 1}"
}
}
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}-${var.environment}-public-rt"
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
EOF
Create the security groups:
cat > security_groups.tf <<'EOF'
resource "aws_security_group" "web" {
name = "${var.project}-${var.environment}-web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from bastion"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.bastion_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project}-${var.environment}-web-sg"
}
}
EOF
Create the EC2 instance configuration:
cat > ec2.tf <<'EOF'
resource "aws_instance" "web" {
count = var.web_instance_count
ami = data.aws_ami.rhel7.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[count.index % length(aws_subnet.public)].id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = var.key_name
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
}
user_data = templatefile("${path.module}/userdata.sh.tpl", {
environment = var.environment
project = var.project
})
tags = {
Name = "${var.project}-${var.environment}-web-${count.index + 1}"
Environment = var.environment
}
}
EOF
Step 2: Using Data Sources
Data sources allow Terraform to read information from your cloud provider without managing those resources. This is essential for referencing existing infrastructure:
cat > data.tf <<'EOF'
# Look up the latest RHEL 7 AMI from AWS
data "aws_ami" "rhel7" {
most_recent = true
owners = ["309956199498"] # Official Red Hat AWS account
filter {
name = "name"
values = ["RHEL-7*GA*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Reference an existing Route 53 zone
data "aws_route53_zone" "main" {
name = var.domain_name
private_zone = false
}
# Get the current AWS account ID
data "aws_caller_identity" "current" {}
# Retrieve an existing IAM role
data "aws_iam_role" "ec2_role" {
name = "EC2-S3-ReadOnly"
}
EOF
Reference data source values using the data. prefix: data.aws_ami.rhel7.id, data.aws_route53_zone.main.zone_id, etc.
Step 3: Using Modules
Modules are reusable, encapsulated Terraform configurations. They work like functions — you call them with input variables and receive outputs. Use the Terraform Registry for community modules or write your own:
cat > modules.tf <<'EOF'
# Use the official AWS VPC module from the Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "${var.project}-${var.environment}"
cidr = var.vpc_cidr
azs = var.availability_zones
public_subnets = var.public_subnets
private_subnets = var.private_subnets
enable_nat_gateway = true
single_nat_gateway = var.environment != "prod"
enable_dns_hostnames = true
tags = local.common_tags
}
# Call a local module
module "web_server" {
source = "./modules/web-server"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnets
environment = var.environment
instance_type = var.instance_type
}
EOF
Define common tags using locals to avoid repeating them:
cat > locals.tf <<'EOF'
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
Owner = "ops-team"
}
}
EOF
Step 4: Configuring a Remote State Backend (S3)
Local state files are dangerous in team environments — two people running terraform apply simultaneously can corrupt state. The S3 backend stores state remotely with DynamoDB-based locking:
cat > backend.tf <<'EOF'
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "aws-env/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
EOF
Create the S3 bucket and DynamoDB table before configuring the backend (use a separate bootstrap Terraform config or AWS CLI):
# Create the S3 bucket
aws s3api create-bucket
--bucket mycompany-terraform-state
--region us-east-1
# Enable versioning (allows state file recovery)
aws s3api put-bucket-versioning
--bucket mycompany-terraform-state
--versioning-configuration Status=Enabled
# Enable encryption
aws s3api put-bucket-encryption
--bucket mycompany-terraform-state
--server-side-encryption-configuration
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
# Create the DynamoDB lock table
aws dynamodb create-table
--table-name terraform-state-lock
--attribute-definitions AttributeName=LockID,AttributeType=S
--key-schema AttributeName=LockID,KeyType=HASH
--billing-mode PAY_PER_REQUEST
--region us-east-1
After adding backend.tf, run terraform init again to migrate any existing local state to the remote backend.
Step 5: Managing Multiple Environments with Workspaces
Terraform workspaces allow you to maintain separate state files for different environments (dev, staging, prod) using the same configuration:
# List existing workspaces
terraform workspace list
# Create a new workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Switch to a workspace
terraform workspace select staging
# Show current workspace
terraform workspace show
Reference the current workspace in your configuration:
# In variables.tf or locals.tf
locals {
environment = terraform.workspace
instance_counts = {
dev = 1
staging = 2
prod = 4
}
web_count = local.instance_counts[terraform.workspace]
}
Use separate .tfvars files per environment and pass them explicitly:
terraform workspace select prod
terraform apply -var-file="environments/prod.tfvars"
Step 6: Importing Existing Resources
When you have infrastructure that was created outside of Terraform, use terraform import to bring it under management:
# Import an existing EC2 instance by instance ID
terraform import aws_instance.web i-1234567890abcdef0
# Import an existing security group
terraform import aws_security_group.web sg-12345678
# Import an existing S3 bucket
terraform import aws_s3_bucket.assets mybucket-name
Before importing, write the resource block in your .tf files with the correct resource type and name, then run the import command. After import, run terraform plan to see what differences exist between your configuration and the imported resource state — adjust your .tf files until the plan shows no changes.
Step 7: terraform fmt and validate Best Practices
Consistent code style and syntactic correctness are essential for team collaboration. Terraform provides built-in tools for both:
# Format all .tf files in the current directory recursively
terraform fmt -recursive
# Check formatting without modifying files (useful in CI/CD)
terraform fmt -check -recursive
# Validate configuration syntax and internal consistency
terraform validate
A valid terraform validate output looks like:
Success! The configuration is valid.
Integrate these commands into your CI/CD pipeline to prevent merging unformatted or invalid configurations:
# Example CI check script
#!/bin/bash
set -e
cd ~/terraform/aws-env
echo "Checking Terraform formatting..."
terraform fmt -check -recursive
echo "Validating configuration..."
terraform init -backend=false
terraform validate
echo "All checks passed."
Additional best practices for production Terraform use on RHEL 7 include pinning provider versions in required_providers, storing terraform.tfvars files outside version control if they contain sensitive values, using terraform plan -out=plan.tfplan and reviewing before applying, and running terraform show -json plan.tfplan to programmatically inspect planned changes in CI pipelines.
By combining VPC networking, security groups, data sources for dynamic lookups, community modules, S3 remote state with locking, workspaces for environment isolation, and import for legacy infrastructure, you have a complete Terraform workflow suitable for production use. The infrastructure-as-code approach means your entire cloud environment is auditable, reproducible, and peer-reviewable — the same discipline you apply to application code now extends to the platform that runs it.