Futuristic Tech Nodes
NOTES FROM THE CLOUD TRENCHES
TerraformInfrastructure as CodeDevOpsAWS

Terraform for Beginners: From Your First File to a Reusable Module

Raphael Gab-Momoh

Raphael Gab-Momoh

Senior DevOps Instructor, SKILL.SCH

10/14/2026 14 min read
Share LinkedIn WhatsApp
Terraform for Beginners: From Your First File to a Reusable Module

Clicking around a cloud console is how most people start, and it is fine for learning. The problem shows up later: nobody remembers which settings were changed, the test environment does not match production, and rebuilding anything takes a whole afternoon of screenshots and guesswork. Terraform fixes this by letting you describe infrastructure in text files that you can review, version and run again. This guide takes you from installing Terraform to writing your first file, managing state safely and packaging your work into a reusable module, with commands you can run today.

What Terraform actually is

Terraform is an infrastructure as code (IaC) tool made by HashiCorp. You write configuration files in HCL (HashiCorp Configuration Language) that describe what you want: a storage bucket, a virtual network, a server, a DNS record. Terraform compares that description with what already exists and works out the steps needed to make reality match the file.

Three ideas carry most of the weight:

  • Declarative: you describe the end result, not the sequence of clicks. "I want one bucket with versioning on", not "go to S3, click Create, tick this box".
  • Providers: plugins that teach Terraform how to talk to a platform. There are providers for AWS, Azure, Google Cloud, Kubernetes, GitHub, Cloudflare and hundreds more.
  • State: a file where Terraform records what it has created, so next time it knows what to change rather than creating everything again.

You will also hear about OpenTofu. In 2023 HashiCorp moved Terraform to the Business Source License, and a community fork called OpenTofu was created under the Linux Foundation. For a beginner the two are almost identical: the language, the commands and most providers are shared. Learn Terraform first, because it is what most job adverts name, and you will be able to use OpenTofu with very little extra effort.

Why it matters for a cloud career

Almost every serious cloud team manages at least part of its infrastructure as code, and Terraform is one of the most common tools for it. Knowing it signals that you can work in a team: your changes are written down, reviewed in a pull request and repeatable.

It also matters for your own wallet. Cloud bills are charged in dollars, and a forgotten load balancer or database running for a month can hurt when your card is a naira card with a tight foreign spending limit. With Terraform, you can create a full lab in two minutes and remove all of it with one command. That habit alone will save you money while you learn.

ApproachGood forWeak at
Console clickingExploring a service for the first timeRepeating, reviewing, tracking changes
Cloud CLI scripts (aws, az)Quick one-off tasksKnowing what already exists, clean updates
Terraform / OpenTofuRepeatable environments across many providersHas a learning curve, state needs care
CloudFormation / BicepDeep single-cloud integration (AWS / Azure)Only work with their own cloud

If you are still choosing a cloud, our comparison of AWS vs Azure for beginners will help. Terraform works with both.

Before you start

You need:

  • A terminal you are comfortable in. If cd, ls and editing a file feel unfamiliar, spend a week on Linux basics first.
  • Git, so every change you make is tracked.
  • A code editor. VS Code with the official HashiCorp Terraform extension gives you syntax highlighting and autocompletion.
  • Later in this guide, an AWS account. The first exercises need no cloud account at all.

Step 1: Install Terraform

On Ubuntu or Debian (including WSL on Windows), use HashiCorp's official package repository:

sudo apt-get update && sudo apt-get install -y gnupg software-properties-common wget
wget -O- https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /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-get update && sudo apt-get install -y terraform

On macOS with Homebrew:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

On Windows without WSL, winget install Hashicorp.Terraform works, or download the zip from the official Terraform site and add the folder to your PATH. Check it worked:

terraform -version

If your internet is unreliable, note that terraform init downloads providers, which can be tens of megabytes each. Do that step when you have a stable connection, and the rest of your work can happen offline until you apply to a real cloud.

Step 2: Your first configuration, with no cloud bill

The best first project costs nothing. We will use two small providers, random and local, that run entirely on your machine.

mkdir tf-first && cd tf-first
git init

Create a file called main.tf:

terraform {
  required_version = ">= 1.6"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

resource "random_pet" "name" {
  length = 2
}

resource "local_file" "greeting" {
  filename = "${path.module}/hello.txt"
  content  = "Hello from ${random_pet.name.id}, built by Terraform.\n"
}

output "pet_name" {
  value = random_pet.name.id
}

Read it slowly. A resource block has a type (random_pet) and a local name (name). The second resource refers to the first with random_pet.name.id, and Terraform uses that reference to work out that the pet must exist before the file. You never write the order yourself.

Now run the core workflow:

terraform init      # downloads the providers into .terraform/
terraform fmt       # tidies formatting
terraform validate  # checks the syntax and references
terraform plan      # shows what would change
terraform apply     # makes the change (type yes to confirm)

The plan output uses symbols you will see every day: + for create, ~ for update in place, - for destroy and -/+ for destroy and recreate. Learn to read plans carefully. In real jobs, the plan is what reviewers look at before approving a change.

After apply, open hello.txt, then run terraform output pet_name. Now change the content string in main.tf, run terraform plan again, and notice that Terraform only wants to replace the file, not the pet. Finally:

terraform destroy

Add a .gitignore before your first commit:

.terraform/
*.tfstate
*.tfstate.*
*.tfvars
crash.log

Do commit .terraform.lock.hcl. It pins the exact provider versions so everyone on the team gets the same ones.

Step 3: Variables, outputs and a real cloud resource

Now a real AWS resource. Before you touch any cloud, do two things:

  1. Set a budget alert. In the AWS Billing console, create a monthly budget of a few dollars with an email alert. Many Nigerian bank cards have limits on dollar spending, and some get declined for verification charges; a virtual dollar card from a reputable provider is a common workaround. Whatever card you use, the budget alert is not optional.
  2. Do not use the root user. Create an IAM user or, better, use IAM Identity Center, and configure the AWS CLI with aws configure or aws configure sso. Terraform's AWS provider reads the same credentials.

Free tier rules change, so check the official AWS free tier page for what is currently included. An S3 bucket with a few test files costs very little, which makes it a good first resource.

Split your work into files. Terraform reads every .tf file in the folder, so the names are only for humans.

versions.tf:

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project   = var.project
      ManagedBy = "terraform"
    }
  }
}

variables.tf:

variable "region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "eu-west-1"
}

variable "project" {
  description = "Short project name, used in resource names and tags"
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.project))
    error_message = "Use 3-20 lowercase letters, numbers or hyphens."
  }
}

main.tf:

resource "random_id" "suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "site" {
  bucket = "${var.project}-${random_id.suffix.hex}"
}

resource "aws_s3_bucket_versioning" "site" {
  bucket = aws_s3_bucket.site.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket                  = aws_s3_bucket.site.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

outputs.tf:

output "bucket_name" {
  value = aws_s3_bucket.site.bucket
}

Because random_id comes from the random provider, add it to required_providers too (copy the block from Step 2). Then create terraform.tfvars:

project = "skillsch-lab"

Run terraform init, terraform plan and terraform apply. Bucket names are global across all AWS accounts, which is why we add a random suffix. Region choice is up to you; af-south-1 (Cape Town) is the closest AWS region to West Africa but must be enabled on your account first and can be priced differently, so check before you use it.

Notice that S3 versioning and the public access block are separate resources in recent versions of the AWS provider. That pattern is common, and reading the provider documentation on the Terraform Registry is a skill you will use constantly.

When you are done for the day, run terraform destroy. If a bucket has objects in it, the destroy will fail unless you empty it first or set force_destroy = true on the bucket (fine for labs, dangerous in production).

Step 4: Understand state before it bites you

After apply, look at terraform.tfstate. It is JSON that maps your resource names to real IDs in AWS. Three rules:

  • Never edit it by hand. Use terraform state list, terraform state show <address>, terraform state mv and terraform state rm if you must change it.
  • Never commit it to Git. State can contain secrets such as database passwords in plain text.
  • Share it through a remote backend as soon as more than one person, or one CI pipeline, works on the same infrastructure.

A common AWS setup stores state in S3. Since Terraform 1.10 the S3 backend can lock state using S3 itself, so you no longer need a separate DynamoDB table:

terraform {
  backend "s3" {
    bucket       = "your-unique-state-bucket"
    key          = "labs/s3-site/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

Create the state bucket once (by hand or in a separate small Terraform project), turn on versioning so you can recover old state, and then run terraform init -migrate-state to move your local state across. Locking matters because two people applying at the same time can corrupt state; with a lock, the second person gets an error and waits.

Another state-related command worth knowing early is terraform import, or the newer import block, which brings a resource created in the console under Terraform's management. In real jobs you will often inherit "click-ops" infrastructure and have to adopt it this way.

Step 5: Turn it into a reusable module

A module is just a folder of .tf files with inputs (variables) and outputs. You have already written one: the folder you run Terraform from is the root module. Child modules let you package a pattern once and reuse it for dev, staging and production, or across projects.

Restructure your project like this:

tf-s3/
├── modules/
│   └── private-bucket/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── envs/
│   ├── dev/
│   │   ├── main.tf
│   │   └── versions.tf
│   └── prod/
│       ├── main.tf
│       └── versions.tf

modules/private-bucket/variables.tf:

variable "name_prefix" {
  type = string
}

variable "versioning" {
  type    = bool
  default = true
}

variable "force_destroy" {
  type    = bool
  default = false
}

modules/private-bucket/main.tf:

resource "random_id" "suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "this" {
  bucket        = "${var.name_prefix}-${random_id.suffix.hex}"
  force_destroy = var.force_destroy
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioning ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

modules/private-bucket/outputs.tf:

output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

Now envs/dev/main.tf calls the module (put the terraform and provider blocks from Step 3 in envs/dev/versions.tf, with random and aws providers listed):

module "uploads" {
  source        = "../../modules/private-bucket"
  name_prefix   = "skillsch-dev-uploads"
  force_destroy = true
}

module "logs" {
  source      = "../../modules/private-bucket"
  name_prefix = "skillsch-dev-logs"
  versioning  = false
}

output "uploads_bucket" {
  value = module.uploads.bucket_name
}

Run terraform init inside envs/dev (init is needed whenever you add a module), then plan and apply. Two buckets, same safe defaults, a few lines each. The production folder calls the same module with different values and, importantly, its own state. Separate folders per environment is the simplest pattern to understand and the one we recommend for beginners; Terraform workspaces exist too, but they make it easy to apply to the wrong environment by mistake.

Good module habits:

  • Keep inputs few and give them sensible defaults. A module with forty variables is a module nobody wants to use.
  • Put description on every variable and output.
  • Make the safe choice the default (private, encrypted, versioned).
  • Once a module is shared, version it: store it in its own Git repository and reference a tag, for example source = "git::https://github.com/your-org/tf-private-bucket.git?ref=v1.0.0".
  • Look at well-maintained public modules on the Terraform Registry to learn structure, but read them before using them.

Common mistakes beginners make

  • Committing state or `.tfvars` files with secrets. Check git status before every commit. If a key leaks, rotate it immediately; deleting the commit is not enough.
  • Hard-coding access keys in the provider block. Use the AWS CLI profile, SSO or environment variables instead.
  • Not reading the plan. A -/+ on a database means it will be destroyed and recreated. Stop and find out why before typing yes.
  • Changing things in the console after Terraform created them. This is called drift. The next plan will try to undo your change. Make changes in code.
  • Leaving labs running. Destroy at the end of every session. Pair this with a budget alert.
  • Unpinned provider versions. Without a version constraint, a major provider release can break your code on the next init.
  • One giant folder for everything. A single state file for all environments means a mistake in dev can touch production. Split by environment and, later, by component.
  • Using `count` with lists that change order. Removing an item from the middle renumbers everything after it. Prefer for_each with a map or set of strings.

A realistic 6-week practice plan

Plan for about five to seven hours a week. If power or data is unpredictable, do the reading and writing offline and save init and apply for when you have a stable connection.

WeekFocusWhat you should finish
1Install, core workflow, random and local providersStep 2 done from memory, pushed to GitHub with a good .gitignore
2Variables, outputs, tfvars, validation, first AWS resourcesThe S3 lab applied and destroyed, budget alert in place
3State: remote backend, locking, state list/show, importState in S3 with locking; one console-made resource imported
4Expressions: for_each, count, locals, conditionals, data sourcesA small VPC with public and private subnets driven by a map
5ModulesYour own module used by dev and prod folders
6Automation and polishA GitHub Actions workflow that runs fmt -check, validate and plan on pull requests, plus a clear README

For week 6, our CI/CD with GitHub Actions guide covers the pipeline basics. If you want guided drills between sessions, the Terraform Arena gives you short challenges, and CloudVision Terraform shows visually how resources, state and plans relate.

At the end of six weeks, that repository is a genuine portfolio piece. Add a diagram and a section in the README explaining your choices. Our list of cloud portfolio projects has ideas for extending it, such as deploying a container platform behind it.

What to learn next

  • Another cloud with the same tool. Rewrite your bucket module for Azure Storage with the azurerm provider. You will see how much of Terraform carries over.
  • Kubernetes and Helm providers, once you know Kubernetes basics.
  • Policy and security checks. Tools such as Checkov, tflint and Trivy scan your code for insecure settings before apply. Catching a public bucket in review is far cheaper than catching it in production.
  • Certification. HashiCorp offers the Terraform Associate certification. It is a reasonable goal after a couple of months of hands-on practice. Check the official HashiCorp certification site for the current exam version, objectives and price, since these change.

FAQ

Do I need to know programming to learn Terraform?

No. HCL is a configuration language, not a full programming language. If you can read a JSON or YAML file and follow logic like "if this, then that", you can start. Programming helps later with loops and expressions, but it is not a prerequisite.

Should I learn Terraform or OpenTofu?

Start with Terraform because it is more commonly named in job adverts and has the most learning material. The skills transfer almost entirely to OpenTofu, and you can try it later by swapping the terraform command for tofu.

Can I practise without a credit card?

Yes, partly. Everything in Step 2 runs locally with no account. You can also practise against Docker with the kreuzwerker/docker provider on your own machine. For real cloud work you will need an account with a payment method; keep resources small and destroy them after each session.

Is Terraform only for AWS?

No. It works with AWS, Azure, Google Cloud, Kubernetes, GitHub, DNS providers and many others. The workflow and language are the same; only the resource types change.

How long before I can use Terraform at work?

That depends on your starting point and how much you practise. Following the six-week plan above with steady effort gives you enough to contribute to an existing codebase under review, which is how most people start using it in a job.

Get help planning your path

Terraform is one piece of a cloud and DevOps skill set, alongside Linux, Git, containers and CI/CD. If you want structured, hands-on training with feedback on your work, look at our courses, or book a free consultation and we will help you work out where Terraform fits in your plan and what to learn first.