Your First Terraform Config
The fastest way to understand Terraform is to provision something real with it. In this guide you will write a single main.tf file, run the core init → plan → apply cycle, and inspect what Terraform created. We start with the local provider so you can follow along with zero cloud credentials, then show the equivalent AWS S3 bucket so the pattern translates directly to real infrastructure. Everything here works identically with the tofu command if you use OpenTofu.
Anatomy of a Terraform file
A Terraform configuration is a set of .tf files in a directory. Terraform reads every .tf file in the working directory and merges them, so file names are organizational, not significant. A minimal config has three kinds of blocks: a terraform block declaring version and provider requirements, one or more provider blocks configuring those providers, and resource blocks describing the infrastructure you want to exist.
Terraform is declarative: you describe the desired end state and Terraform figures out the create, update, or delete actions needed to reach it. You never write imperative “create this, then that” steps.
Writing main.tf
Create an empty directory and add a main.tf. This example uses the official hashicorp/local provider to manage a file on disk — a real provider with real behavior, but one that needs no account.
terraform {
required_version = ">= 1.5.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
provider "local" {}
resource "local_file" "greeting" {
filename = "${path.module}/hello.txt"
content = "Provisioned by Terraform on ${formatdate("YYYY-MM-DD", timestamp())}\n"
}
output "file_path" {
value = local_file.greeting.filename
}
The resource keyword is followed by the resource type (local_file) and a local name (greeting) you choose. Together they form the address local_file.greeting, which you use to reference the resource elsewhere. The output block surfaces a value after apply.
Initializing the working directory
Before Terraform can do anything it must download the providers declared in required_providers. That is what terraform init does — it reads the config, fetches provider plugins into .terraform/, and writes a .terraform.lock.hcl lock file pinning exact versions and checksums.
terraform init
Output:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.5.2...
- Installed hashicorp/local v2.5.2 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above.
Terraform has been successfully initialized!
Tip: Commit
.terraform.lock.hclto version control but add.terraform/to.gitignore. The lock file guarantees teammates and CI use identical provider versions; the.terraform/directory just holds downloaded binaries thatinitrecreates.
Previewing changes with plan
terraform plan compares your configuration against the recorded state and prints exactly what it will do, without changing anything. On a fresh project the state is empty, so every resource shows as a create (+).
terraform plan
Output:
Terraform will perform the following actions:
# local_file.greeting will be created
+ resource "local_file" "greeting" {
+ content = "Provisioned by Terraform on 2026-06-14\n"
+ content_base64sha256 = (known after apply)
+ filename = "./hello.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ file_path = "./hello.txt"
Values marked (known after apply) are computed by the provider during apply — Terraform cannot know them until the resource exists. Always read the plan before applying; it is your safety check.
Applying the configuration
terraform apply runs a fresh plan, shows it, and asks for confirmation before executing. Type yes to proceed.
terraform apply
Output:
Enter a value: yes
local_file.greeting: Creating...
local_file.greeting: Creation complete after 0s [id=a1b2c3...]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello.txt"
Terraform recorded the result in a new terraform.tfstate file. This state is the source of truth that maps your config to real-world objects; on the next run Terraform reads it to detect drift and compute the diff.
Inspecting the result
You can read outputs and inspect state at any time without re-planning.
cat hello.txt
terraform output file_path
terraform state list
Output:
Provisioned by Terraform on 2026-06-14
"./hello.txt"
local_file.greeting
Running terraform plan again now reports No changes because the desired state already matches reality — proof that Terraform is idempotent.
The same config for AWS
The structure is identical for real cloud resources; only the provider and resource type change. Below, an S3 bucket replaces the local file. This requires configured AWS credentials (via aws configure or environment variables).
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "first" {
bucket = "devcraftly-first-config-2026"
tags = {
ManagedBy = "Terraform"
Example = "first-config"
}
}
output "bucket_arn" {
value = aws_s3_bucket.first.arn
}
The init → plan → apply cycle is exactly the same. S3 bucket names are globally unique, so change the bucket value to something nobody else has taken.
Warning:
applycreates billable cloud resources. When experimenting, runterraform destroyafterward to tear everything down and avoid charges.destroyreverses the apply, deleting every resource tracked in state.
Best Practices
- Always run
terraform planand read it beforeapply— treat the plan as a code review of your infrastructure changes. - Pin provider versions with
~>constraints and commit.terraform.lock.hclso builds are reproducible. - Keep
terraform.tfstateout of Git; for any shared project use a remote backend (such as S3 + DynamoDB or Terraform Cloud) instead of local state. - Use
path.modulerather than hardcoded relative paths so configs work regardless of where they are invoked. - Tag cloud resources from day one; it makes ownership and cleanup far easier as projects grow.
- Run
terraform destroyon throwaway experiments so you never leak running, billable infrastructure.