Terraform lets you describe AWS infrastructure in plain text files, review the exact changes before they happen, and rebuild the whole environment from scratch whenever you need to. This guide walks through a small but complete AWS lab built from an AlmaLinux 10 workstation: a VPC with public subnets, a security group, an EC2 instance running AlmaLinux, an encrypted S3 bucket, and remote state stored in S3 with native locking.
Versions at the time of writing: Terraform 1.16.6, the hashicorp/aws provider 6.x, AWS CLI v2, and AlmaLinux 10.2. Every example here passes terraform validate and terraform fmt -check. The same steps work on AlmaLinux 9.x, Rocky Linux, Oracle Linux, RHEL and CentOS Stream.
What you will build
- A VPC (
10.20.0.0/16) with two public subnets in different Availability Zones, an internet gateway and a route table - A security group allowing SSH only from your IP and HTTP from anywhere
- An EC2 instance using the latest official AlmaLinux 10 AMI, IMDSv2 enforced, encrypted gp3 root disk, and nginx installed by user data
- A versioned, encrypted, private S3 bucket
- Remote Terraform state in S3 with
use_lockfilelocking (no DynamoDB table needed)
Prerequisites
- An AlmaLinux 10 (or 9.x) machine with sudo access and outbound HTTPS
- An AWS account and an IAM user or IAM Identity Center (SSO) role allowed to manage EC2, VPC and S3. Avoid using the root account.
- An SSH key pair on your workstation. If you need one, see Configure SSH Key Authentication Only.
- A few dollars of budget. A
t3.microis free-tier eligible on many accounts, but destroy the lab when you are done.
Step 1: Install Terraform from the HashiCorp repository
HashiCorp publishes an official RPM repository for RHEL-compatible systems. Add it and install Terraform:
sudo dnf install -y dnf-plugins-core unzip
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y terraform
terraform -version
You should see Terraform v1.16.x or newer. Enable tab completion for your shell while you are here:
terraform -install-autocomplete
source ~/.bashrc
Step 2: Install and configure the AWS CLI
Terraform does not need the AWS CLI, but it is the easiest way to set up credentials and to check your work. Install AWS CLI v2 with Amazon’s bundled installer:
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
unzip -q awscliv2.zip
sudo ./aws/install
aws --version
Configure credentials. If your organisation uses IAM Identity Center, use aws configure sso; otherwise use an access key for a dedicated IAM user:
aws configure
# AWS Access Key ID [None]: AKIA...
# AWS Secret Access Key [None]: ****
# Default region name [None]: us-east-2
# Default output format [None]: json
aws sts get-caller-identity
The AWS provider reads the same ~/.aws/credentials and ~/.aws/config files, or the AWS_PROFILE, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables. Never put access keys inside .tf files.
Step 3: Create the project layout
mkdir -p ~/terraform/aws-lab && cd ~/terraform/aws-lab
touch versions.tf variables.tf network.tf security.tf compute.tf storage.tf outputs.tf terraform.tfvars
Terraform loads every .tf file in the directory, so splitting by purpose is just for readability.
Example 1: Pin Terraform and the AWS provider
versions.tf pins the provider to the 6.x series and applies default tags to every resource Terraform creates, which makes cost reports and cleanup much easier:
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = var.project
ManagedBy = "terraform"
}
}
}
Example 2: Input variables
variables.tf:
variable "region" {
description = "AWS region to deploy into"
type = string
default = "us-east-2"
}
variable "project" {
description = "Name prefix for every resource"
type = string
default = "zwiegnet-lab"
}
variable "vpc_cidr" {
type = string
default = "10.20.0.0/16"
}
variable "admin_cidr" {
description = "Your public IP in CIDR form, allowed to SSH in"
type = string
}
variable "instance_type" {
type = string
default = "t3.micro"
}
Set your own values in terraform.tfvars. Find your public IP with Amazon’s checkip service:
curl -s https://checkip.amazonaws.com
cat > terraform.tfvars <<'EOF'
region = "us-east-2"
project = "zwiegnet-lab"
admin_cidr = "203.0.113.25/32"
EOF
Example 3: A VPC with public subnets
network.tf creates the VPC, two public subnets spread across Availability Zones using count and cidrsubnet(), an internet gateway, and a default route:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project}-igw" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project}-public-${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}-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
}
cidrsubnet("10.20.0.0/16", 8, 0) returns 10.20.0.0/24 and index 1 returns 10.20.1.0/24, so adding subnets is just a matter of raising count.
Example 4: Security group rules
Provider 5.x and later recommend the standalone aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources instead of inline ingress blocks. Each rule is managed on its own, so changing one never forces the others to be replaced. security.tf:
resource "aws_security_group" "web" {
name = "${var.project}-web"
description = "SSH from admin, HTTP from anywhere"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = var.admin_cidr
ip_protocol = "tcp"
from_port = 22
to_port = 22
}
resource "aws_vpc_security_group_ingress_rule" "http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 80
to_port = 80
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Example 5: An AlmaLinux 10 EC2 instance
Rather than hard-coding an AMI ID that differs in every region and changes with every point release, look it up with the aws_ami data source. 764336703387 is the AWS account the AlmaLinux OS Foundation publishes its official AMIs from. compute.tf:
data "aws_ami" "almalinux" {
most_recent = true
owners = ["764336703387"] # AlmaLinux OS Foundation
filter {
name = "name"
values = ["AlmaLinux OS 10*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}
resource "aws_key_pair" "admin" {
key_name = "${var.project}-admin"
public_key = file(pathexpand("~/.ssh/id_ed25519.pub"))
}
resource "aws_instance" "web" {
ami = data.aws_ami.almalinux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.admin.key_name
metadata_options {
http_tokens = "required" # IMDSv2 only
}
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
user_data = <<-EOT
#!/bin/bash
dnf -y install nginx
echo "Hello from $(hostname -f)" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOT
tags = { Name = "${var.project}-web1" }
}
Points worth noting:
http_tokens = "required"turns off IMDSv1, which AWS recommends for every new instance.- The user data script runs once at first boot through cloud-init and installs nginx so you can verify HTTP access.
- The default login user on AlmaLinux AMIs is
ec2-user. If you really need direct root access, see Allow root SSH Login Amazon EC2, but sudo fromec2-useris the safer choice. - If you use an RSA key, change the path to
~/.ssh/id_rsa.pub.
Example 6: A private, encrypted S3 bucket
Since provider 4.0, bucket settings live in their own resources. storage.tf adds the account ID to the bucket name to keep it globally unique:
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "artifacts" {
bucket = "${var.project}-artifacts-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Example 7: Outputs
outputs.tf prints the values you need after an apply:
output "vpc_id" {
value = aws_vpc.main.id
}
output "web_public_ip" {
value = aws_instance.web.public_ip
}
output "ssh_command" {
value = "ssh ec2-user@${aws_instance.web.public_ip}"
}
output "ami_name" {
value = data.aws_ami.almalinux.name
}
output "artifacts_bucket" {
value = aws_s3_bucket.artifacts.bucket
}
Step 4: Init, plan and apply
terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
terraform init downloads the AWS provider and writes .terraform.lock.hcl; commit that lock file so everyone uses the same provider build. Saving the plan with -out guarantees that apply does exactly what you reviewed. The first apply takes a minute or two and ends with something like:
Apply complete! Resources: 17 added, 0 changed, 0 destroyed.
Outputs:
ami_name = "AlmaLinux OS 10.2.YYYYMMDD x86_64"
artifacts_bucket = "zwiegnet-lab-artifacts-123456789012"
ssh_command = "ssh ec2-user@3.15.x.x"
vpc_id = "vpc-0abc..."
web_public_ip = "3.15.x.x"
The AMI name, IDs and addresses above are illustrative; yours will differ.
Step 5: Verify the deployment
# Terraform's view
terraform output
terraform state list
# AWS's view
aws ec2 describe-instances --filters "Name=tag:Project,Values=zwiegnet-lab" \
--query "Reservations[].Instances[].[InstanceId,State.Name,PublicIpAddress]" --output table
aws s3api get-bucket-versioning --bucket "$(terraform output -raw artifacts_bucket)"
# The instance itself (give cloud-init a minute after the first boot)
curl "http://$(terraform output -raw web_public_ip)/"
ssh ec2-user@"$(terraform output -raw web_public_ip)" 'cat /etc/almalinux-release; sudo cloud-init status'
A healthy run returns Hello from ... from nginx, "Status": "Enabled" for bucket versioning, and status: done from cloud-init. Run terraform plan once more: it should report No changes, which proves the code matches what is running.
Example 8: Remote state in S3 with native locking
Local terraform.tfstate files are fine for a lab, but a team needs shared state that cannot be edited by two people at once. Since Terraform 1.10 the S3 backend can lock state with an S3 lock file, so a DynamoDB table is no longer required (the dynamodb_table argument is deprecated). Create a dedicated, versioned state bucket once with the CLI:
STATE_BUCKET="zwiegnet-tfstate-$(aws sts get-caller-identity --query Account --output text)"
aws s3api create-bucket --bucket "$STATE_BUCKET" --region us-east-2 \
--create-bucket-configuration LocationConstraint=us-east-2
aws s3api put-bucket-versioning --bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket "$STATE_BUCKET" \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Then add backend.tf, replacing the bucket name with yours. Backend blocks cannot use variables:
terraform {
backend "s3" {
bucket = "zwiegnet-tfstate-123456789012"
key = "lab/terraform.tfstate"
region = "us-east-2"
encrypt = true
use_lockfile = true
}
}
Move the existing local state into the bucket:
terraform init -migrate-state
aws s3 ls "s3://$STATE_BUCKET/lab/"
While a plan or apply is running you will see a terraform.tfstate.tflock object next to the state file; it disappears when the run finishes.
Step 6: Destroy the lab
terraform destroy
Type yes to confirm. The artifacts bucket must be empty before it can be deleted; if you uploaded objects, empty it first or set force_destroy = true on the bucket resource and apply before destroying. Leave the state bucket in place if you plan to reuse it.
Troubleshooting
No valid credential sources foundorExpiredToken: runaws sts get-caller-identity. For SSO profiles, runaws sso login --profile NAMEandexport AWS_PROFILE=NAME.Your query returned no resultsfromaws_ami: AlmaLinux AMIs are published in most but not all regions, and some accounts must accept the product on AWS Marketplace first. Test the filter withaws ec2 describe-images --owners 764336703387 --filters "Name=name,Values=AlmaLinux OS 10*" --query "Images[].Name".BucketAlreadyExists: S3 bucket names are global across all AWS customers. Keep the account ID suffix or choose another prefix.Unsupported argumentafter upgrading the provider: read the provider upgrade guide for the new major version, or pin the previous one withversion = "~> 5.0"until you have migrated.- SSH times out: check that
admin_cidrstill matches your current public IP (home IPs change), the instance is in a public subnet, and the route table has the0.0.0.0/0route to the internet gateway. - nginx not answering: SSH in and run
sudo cloud-init status --longandsudo journalctl -u cloud-finalto see whether the user data script failed. Error acquiring the state lock: another run is in progress, or a crashed run left the lock behind. Once you are sure nothing else is running, useterraform force-unlock LOCK_ID.- Config drift: if someone changed a resource in the console,
terraform planshows the difference. Either apply to put it back or update the code to match.
Next steps
From here, replace the hand-built network with the community terraform-aws-modules/vpc/aws module, split environments into separate state keys, and run terraform plan in CI on every pull request. The next posts in this series cover the same patterns on Google Cloud, Microsoft Azure and Oracle Cloud Infrastructure.
