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 --version to confirm)
  • A text editor — vim or nano are 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.