Last reviewed: May 2026
Build the AWS services on the CLF-C02 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, the smallest realistic first AWS workload — a private encrypted S3 bucket, a least-privilege IAM role, a free-tier EC2 instance, and a billing alarm that emails you before AWS does. Every resource maps to one of the four CLF-C02 exam domains.
Every resource is plain Terraform — the same code works without modification on OpenTofu. There are no variables, no modules, no remote state. Drop the snippets that follow into a single main.tf, run terraform init once, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.us-east-1 because it's where billing data is centralized.All resources in this lab fit within AWS Free Tier for new accounts (12 months from sign-up):
t3.micro instance: 750 hours/month free.Outside Free Tier, the whole stack costs under $10/month even running 24/7. That said: idle resources still get charged once Free Tier expires — destroy when you're done. The billing alarm we build in Step 4 is your safety net if you forget.
Every workload begins with telling Terraform which version of itself we expect and which AWS provider we'll use. We pin the AWS provider to ~> 5.60 and default to us-east-1 — AWS's oldest region, where billing data is centralized and most services launch first.
Drop this into a fresh main.tf to start. Everything that follows in the lab sits in the same file.
The default_tags block in the provider attaches the same tags to every taggable resource we'll create. AWS Cost Explorer + Cost Allocation Reports group spending by tag — getting this right on day one is the cheapest cost-control investment you'll ever make, and it maps directly to CLF-C02's Billing and Pricing domain.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "certlabpro-clf-c02"
ManagedBy = "terraform"
Environment = "lab"
}
}
}S3 is the storage layer behind nearly every AWS workload — every CLF-C02 domain references it. We create one bucket, lock down public access (default-on for new buckets since 2023, but explicit-is-better), and turn on server-side encryption with AES256.
These three resources together — bucket, public-access block, encryption — are the minimum responsible default for any S3 bucket. The CLF-C02 exam frames this as the shared responsibility model: AWS gives us a secure platform, but we configure encryption + access controls. The bucket is part of "customer responsibility".
resource "aws_s3_bucket" "app_data" {
bucket_prefix = "certlabpro-clf-c02-"
}
resource "aws_s3_bucket_public_access_block" "app_data" {
bucket = aws_s3_bucket.app_data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "app_data" {
bucket = aws_s3_bucket.app_data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}EC2 is the AWS service every CLF-C02 candidate is expected to know cold. We use the smallest free-tier-eligible instance type (t3.micro) and give it an IAM instance profile so it can read from the bucket we created in Step 2 — without ever embedding AWS credentials on the box. This is the canonical AWS pattern for application-to-AWS authentication and shows up on multiple CLF-C02 question variants.
The data block fetches the latest Amazon Linux 2023 AMI dynamically — pinning to a specific AMI ID would break the lab when AWS retires the image. The security group denies all inbound traffic; we'd add ingress rules in a real workload, but for this lab a locked-down instance is the right default.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-kernel-6.1-x86_64"]
}
}
resource "aws_iam_role" "ec2_app" {
name = "certlabpro-clf-c02-ec2-app"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "ec2_app_s3_read" {
name = "read-app-data-bucket"
role = aws_iam_role.ec2_app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [aws_s3_bucket.app_data.arn, "${aws_s3_bucket.app_data.arn}/*"]
}]
})
}
resource "aws_iam_instance_profile" "ec2_app" {
name = "certlabpro-clf-c02-ec2-app"
role = aws_iam_role.ec2_app.name
}
resource "aws_security_group" "ec2_app" {
name = "certlabpro-clf-c02-ec2-app"
description = "Locked down by default; add ingress rules per workload."
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.ec2_app.name
vpc_security_group_ids = [aws_security_group.ec2_app.id]
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 only — required for SCS-C03 best practice
}
}CLF-C02 dedicates 16% of the exam to Billing, Pricing, and Support. The most concrete thing you can build in that space is a CloudWatch billing alarm: AWS publishes account-level estimated charges as a metric, and we set a threshold above which we want to know about it. The alarm publishes to an SNS topic, which emails us.
Billing-metric alarms must be created in us-east-1 regardless of where your workloads run — that's why we defaulted to us-east-1 in Step 1. You'll also need to enable Billing alerts once, in the account console at Billing → Billing preferences → Receive Billing Alerts. Terraform can't toggle this; it's an account-level setting outside infrastructure-as-code.
After terraform apply, AWS sends a confirmation email to the address in email_endpoint — click Confirm subscription once, and the alarm will then actually reach you when the threshold trips.
resource "aws_sns_topic" "billing_alerts" {
name = "certlabpro-clf-c02-billing-alerts"
}
resource "aws_sns_topic_subscription" "billing_alerts_email" {
topic_arn = aws_sns_topic.billing_alerts.arn
protocol = "email"
endpoint = "you@example.com" # replace with your real email
}
resource "aws_cloudwatch_metric_alarm" "monthly_bill_over_10" {
alarm_name = "certlabpro-clf-c02-monthly-bill-over-10-usd"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "EstimatedCharges"
namespace = "AWS/Billing"
period = 21600 # 6 hours — billing metric is slow-moving
statistic = "Maximum"
threshold = 10
alarm_description = "Estimated monthly AWS charges exceeded $10 USD."
alarm_actions = [aws_sns_topic.billing_alerts.arn]
dimensions = {
Currency = "USD"
}
}A standard terraform destroy tears down everything in this lab. Two notes:
Project = certlabpro-clf-c02 and confirm nothing is lingering.CLF-C02 covers a wide service catalog — RDS, Lambda, VPC details, CloudFront, Route 53, ECS, EKS, Aurora, Elastic Beanstalk, AppSync, and many more. We deliberately don't provision them in this foundational lab.
The goal of CLF-C02 is broad conceptual fluency, not deep service-by-service provisioning. The four steps above touch one resource from each of the four exam domains — Cloud Concepts (IAM + S3 as the platform-and-storage primitives), Security and Compliance (least-privilege IAM, encryption at rest, IMDSv2), Cloud Technology and Services (EC2 as the canonical compute service), and Billing, Pricing, and Support (the CloudWatch billing alarm). That's the right shape for the exam.
For service-by-service coverage, see the Browse and Editorial sections of this cert page — they reference every service in the CLF-C02 scope with one-line descriptions and exam-domain ties. The hands-on value of this lab is in seeing how the four pillars connect on day one, not in touring all 200+ AWS services.