Terraform Examples for Amazon AWS from AlmaLinux 10

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_lockfile locking (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.micro is 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 from ec2-user is 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 found or ExpiredToken: run aws sts get-caller-identity. For SSO profiles, run aws sso login --profile NAME and export AWS_PROFILE=NAME.
  • Your query returned no results from aws_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 with aws 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 argument after upgrading the provider: read the provider upgrade guide for the new major version, or pin the previous one with version = "~> 5.0" until you have migrated.
  • SSH times out: check that admin_cidr still matches your current public IP (home IPs change), the instance is in a public subnet, and the route table has the 0.0.0.0/0 route to the internet gateway.
  • nginx not answering: SSH in and run sudo cloud-init status --long and sudo journalctl -u cloud-final to 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, use terraform force-unlock LOCK_ID.
  • Config drift: if someone changed a resource in the console, terraform plan shows 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.

Leave a Reply

Your email address will not be published. Required fields are marked *