title: "Terraform for Beginners: Your First Infrastructure as Code Project in 2026"
slug: "terraform-infrastructure-as-code-beginners-2026"
meta_description: "Learn Terraform from scratch with real HCL examples for AWS. State management, modules, workspaces, and production patterns from a senior cloud architect."
author: "Kenny Ogunlowo"
date: "2026-05-19"
category: "DevOps"
tags: ["terraform", "infrastructure as code", "aws", "devops", "hcl", "beginners", "cloud automation"]
internal_links:
- "/collections/devops-pipelines"
- "/collections/architecture-blueprints"
- "/pages/free-courses"
word_count: 2100
Terraform for Beginners: Your First Infrastructure as Code Project in 2026
When I first joined Patterson UTI as a cloud architect, the infrastructure team was managing hundreds of EC2 instances through a mix of hand-clicked AWS Console actions and homegrown Bash scripts. Rebuilding the same stack in a disaster recovery scenario took two engineers three days. After we moved to Terraform, that rebuild became a fifteen-minute `terraform apply`. That is the promise of Infrastructure as Code — not a theoretical improvement, but a concrete operational shift that changes how your team recovers, scales, and audits.
This guide walks you through exactly what you need to start writing real Terraform code. I will cover the HashiCorp Configuration Language (HCL) syntax, the AWS provider, creating S3 buckets and EC2 instances, managing state, and structuring your code with modules and workspaces. By the end, you will have a working configuration you can adapt for production.
If you are looking for a structured learning path that goes deeper into DevOps automation patterns, the DevOps Pipelines collection at Citadel Cloud has toolkits built specifically for practitioners at this stage.
What Terraform Is (And What It Is Not)
Terraform is an open-source Infrastructure as Code tool built by HashiCorp. You describe the infrastructure you want in HCL files, and Terraform figures out what to create, modify, or destroy to reach that desired state. It is declarative — you describe the end state, not the steps to get there.
Terraform is not a configuration management tool. It does not install software inside your servers. That is the job of Ansible, Chef, or a user-data script. Terraform creates and wires together infrastructure components: compute instances, networks, storage buckets, IAM roles, DNS records, and more.
Terraform works across AWS, Azure, GCP, Kubernetes, Cloudflare, and dozens of other providers through a plugin architecture. In 2026, the Terraform ecosystem has over 3,000 providers. For most teams starting out, AWS is the right place to begin.
Installing Terraform and Setting Up Your Environment
Installation
On macOS with Homebrew:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
On Windows with Chocolatey:
choco install terraform
On Linux (Ubuntu/Debian):
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
Verify the install:
terraform version
# Terraform v1.9.x
AWS Credentials
Terraform needs AWS credentials to create resources. The cleanest approach for local development is to configure the AWS CLI:
aws configure
# AWS Access Key ID: <your-key>
# AWS Secret Access Key: <your-secret>
# Default region name: us-east-1
# Default output format: json
In production CI/CD pipelines, use IAM roles attached to your build agents — never long-lived access keys in environment variables.
Your First HCL File: The AWS Provider
Every Terraform project starts with a provider block. This tells Terraform which cloud or service you are targeting and which version of the plugin to use.
Create a directory for your project and add a file called `main.tf`:
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
provider "aws" {
region = var.aws_region
}
The `~> 5.50` constraint means "any version >= 5.50 and < 6.0.0". This is called a pessimistic version constraint and protects you from breaking changes in major versions.
Add a `variables.tf` file to declare your input variables:
variable "aws_region" {
description = "AWS region to deploy resources into"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment (dev, staging, prod)"
type = string
default = "dev"
}
variable "project_name" {
description = "Project name used for resource naming and tagging"
type = string
default = "citadel-demo"
}
Initialize the project. This downloads the AWS provider plugin:
terraform init
Creating an S3 Bucket
S3 buckets are often the first resource new Terraform practitioners create. Add this to `main.tf`:
resource "aws_s3_bucket" "app_storage" {
bucket = "${var.project_name}-${var.environment}-storage-${random_id.suffix.hex}"
tags = {
Name = "${var.project_name}-storage"
Environment = var.environment
ManagedBy = "terraform"
Owner = "citadel-cloud"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket_versioning" "app_storage" {
bucket = aws_s3_bucket.app_storage.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "app_storage" {
bucket = aws_s3_bucket.app_storage.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "app_storage" {
bucket = aws_s3_bucket.app_storage.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
You need to add the `random` provider to `required_providers`:
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
This configuration does four things most teams forget when clicking around in the console: enables versioning, enforces server-side encryption, blocks all public access, and tags the resource for cost allocation. At Cigna Healthcare, untagged resources were a billing nightmare — Terraform tagging policies solved that by making tags mandatory at the code level.
Launching an EC2 Instance
Add a security group and EC2 instance to `main.tf`:
data "aws_vpc" "default" {
default = true
}
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "state"
values = ["available"]
}
}
resource "aws_security_group" "web_server" {
name = "${var.project_name}-${var.environment}-web-sg"
description = "Security group for web server"
vpc_id = data.aws_vpc.default.id
ingress {
description = "HTTPS from internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP from internet"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_instance" "web_server" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web_server.id]
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
metadata_options {
http_tokens = "required" # IMDSv2 enforced
}
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y nginx
systemctl start nginx
systemctl enable nginx
EOF
tags = {
Name = "${var.project_name}-${var.environment}-web"
Environment = var.environment
ManagedBy = "terraform"
}
}
Add the `instance_type` variable to `variables.tf`:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
Add an `outputs.tf` file to expose useful values:
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web_server.public_ip
}
output "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.app_storage.bucket
}
Now run the workflow:
terraform plan # Preview what will be created
terraform apply # Create the resources (type "yes" to confirm)
terraform destroy # Tear everything down when done
State Management: The Most Critical Concept
When you run `terraform apply`, Terraform writes a `terraform.tfstate` file. This file records the real-world resource IDs that correspond to your configuration. Without state, Terraform cannot know what it already created.
The Problem with Local State
Local state works fine for solo experiments. For team environments, it creates race conditions: two engineers running `terraform apply` simultaneously will corrupt the state file. This happened on a Lockheed Martin project I worked on — two engineers both applied at the same time, state got corrupted, and we spent four hours reconciling infrastructure manually.
Remote State with S3 and DynamoDB
The standard AWS pattern is S3 for storage and DynamoDB for locking. Create the backend resources first (you can use the AWS CLI for this bootstrap step or a separate Terraform config):
# Create the S3 bucket for state
aws s3api create-bucket \
--bucket citadel-terraform-state-prod \
--region us-east-1
# Enable versioning on the state bucket
aws s3api put-bucket-versioning \
--bucket citadel-terraform-state-prod \
--versioning-configuration Status=Enabled
# Create DynamoDB table for state locking
aws dynamodb create-table \
--table-name terraform-state-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
Then configure your backend in `main.tf`:
terraform {
required_version = ">= 1.9.0"
backend "s3" {
bucket = "citadel-terraform-state-prod"
key = "projects/demo/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
Run `terraform init` again after adding the backend. Terraform will prompt you to migrate the existing local state to S3.
Organizing Code with Modules
Modules let you package reusable infrastructure components. Think of them as functions for your infrastructure. Instead of copying S3 bucket configurations across five projects, you write a module once and reference it everywhere.
Creating a Module
Create a directory structure:
terraform-project/
main.tf
variables.tf
outputs.tf
modules/
s3-bucket/
main.tf
variables.tf
outputs.tf
In `modules/s3-bucket/variables.tf`:
variable "bucket_name" {
description = "Name of the S3 bucket"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
variable "enable_versioning" {
description = "Enable S3 bucket versioning"
type = bool
default = true
}
In `modules/s3-bucket/main.tf`:
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "this" {
count = var.enable_versioning ? 1 : 0
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
In `modules/s3-bucket/outputs.tf`:
output "bucket_id" {
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
Call the module from your root `main.tf`:
module "app_storage" {
source = "./modules/s3-bucket"
bucket_name = "${var.project_name}-${var.environment}-data"
environment = var.environment
enable_versioning = true
}
module "logs_storage" {
source = "./modules/s3-bucket"
bucket_name = "${var.project_name}-${var.environment}-logs"
environment = var.environment
enable_versioning = false
}
The Architecture Blueprints collection at Citadel Cloud includes Terraform module templates for multi-tier architectures that follow this exact pattern at enterprise scale.
Workspaces for Environment Management
Workspaces let you manage multiple environments (dev, staging, prod) from the same Terraform configuration. Each workspace has its own state file.
# List workspaces
terraform workspace list
# Create and switch to a new workspace
terraform workspace new staging
terraform workspace new prod
# Switch between workspaces
terraform workspace select dev
terraform workspace select prod
# Check current workspace
terraform workspace show
Reference the workspace name in your configuration:
locals {
environment = terraform.workspace
instance_size = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
}
}
resource "aws_instance" "web" {
instance_type = local.instance_size[local.environment]
# ...
}
The limitation of workspaces: they all share the same backend configuration. For strict isolation between environments — especially when prod requires different AWS accounts — separate configurations with separate state buckets are the better pattern. I have seen workspace-based multi-environment setups get messy at scale. Use workspaces for stateless dev/test environments and separate accounts for staging and prod in regulated environments.
Common Mistakes to Avoid
Not pinning provider versions. Using `version = "latest"` will eventually break your build when a major version releases. Always pin with `~>`.
Storing secrets in `.tfvars` files. Database passwords and API keys should come from environment variables (`TF_VAR_db_password`) or a secrets manager integration, never committed files.
Manual changes to Terraform-managed resources. If you change something in the AWS Console that Terraform manages, the next `terraform plan` will try to reverse your change. Terraform is the source of truth. Enforce this with IAM policies that restrict console changes in prod.
Not running `terraform plan` in CI before merging. Every pull request that touches infrastructure should run `terraform plan` and post the output as a comment. Tools like Atlantis, Spacelift, and Terraform Cloud automate this.
What to Learn Next
Once you are comfortable with the basics covered here, the natural progression is:
- Terraform Cloud or OpenTofu for collaborative workflows and policy enforcement
- Terragrunt for DRY configurations across many modules
- Sentinel policies for governance (requiring encryption, tagging, approved instance types)
- Terratest for automated testing of your modules
- Module registries — the Terraform Registry has vetted community modules for VPCs, EKS, RDS, and more
The free courses at Citadel Cloud include a full DevOps and Terraform track that covers advanced patterns including multi-account architectures, CI/CD integration with GitHub Actions, and policy-as-code with OPA.
FAQ
Do I need to know AWS well before learning Terraform?
You need to understand the AWS services you are provisioning, but you do not need to be an AWS expert first. Learning Terraform and AWS together is a practical approach — each reinforces the other. Start with core services: VPC, EC2, S3, IAM. Those four cover the majority of what you will write in your first few months. If you are brand new to AWS, work through the foundational concepts first, then layer Terraform on top.
Is Terraform still the right choice in 2026 with OpenTofu available?
Terraform remains the industry standard, but OpenTofu — the open-source fork created after HashiCorp changed Terraform's license to BUSL in 2023 — is a fully compatible alternative with strong community momentum. The HCL syntax and provider ecosystem work identically between them. For teams in regulated industries with existing Terraform tooling, stay with Terraform. For greenfield projects that prioritize open-source purity, OpenTofu is a solid choice. The skills are interchangeable.
How does Terraform compare to AWS CloudFormation?
CloudFormation is AWS-native and integrates tightly with AWS services like StackSets and Service Catalog. Terraform is cloud-agnostic and has a cleaner developer experience in most engineers' opinion. If you operate exclusively on AWS and are heavily embedded in the AWS ecosystem, CloudFormation is viable. If you work across clouds or want a consistent tool across providers, Terraform wins. I have used both in production environments and found Terraform substantially more maintainable at scale. See the DevOps Pipelines collection for IaC comparison guides.
What happens if someone deletes a resource that Terraform manages?
Terraform will detect the drift on the next `terraform plan` and show a resource marked for creation. Running `terraform apply` will recreate it. You can also run `terraform refresh` to sync the state with actual infrastructure without making changes. For critical resources, use the `lifecycle { prevent_destroy = true }` argument to block accidental deletions through Terraform itself.
How do I handle sensitive values like database passwords in Terraform?
Never put secrets directly in `.tf` or `.tfvars` files that get committed to version control. The recommended approaches are: environment variables prefixed with `TF_VAR_` (e.g., `TF_VAR_db_password`), AWS Secrets Manager or HashiCorp Vault with provider data sources, or a CI/CD system that injects secrets at runtime. Mark output values that contain secrets with `sensitive = true` to prevent them from appearing in plan output.
*Kenny Ogunlowo is a Senior Multi-Cloud DevSecOps Architect with enterprise experience at Cigna Healthcare, Lockheed Martin, NantHealth, BP Refinery, and Patterson UTI. He holds AWS, Azure, and GCP certifications with specializations in FedRAMP, CMMC, and HIPAA compliance environments.*