In my first Terraform post everything lived in a single file and the focus was the CLI workflow. This time I want to slow down and look at what that file actually contains, because almost every Terraform configuration you will ever read is made of the same three top-level blocks.
The example lives in my TerraformExamples learning repo. It provisions an EC2 instance that installs nginx on first boot, along with a security group that opens port 80 so the page is actually reachable.
| Block | What it is for |
|---|---|
terraform { } |
Settings for Terraform itself: which CLI version and which providers this config needs |
provider "aws" { } |
How to reach the target platform: region and credentials |
resource "aws_instance" "..." { } |
The actual infrastructure you want to exist |
Pre-requisites
Install the Terraform CLI using HashiCorp's install guide and confirm it worked.
terraform --version
Install the AWS CLI as well, then run aws configure and provide your access key, secret key, and default region. That is what writes the credentials file Terraform reads from later.
A few things worth checking before you run anything:
- A default VPC must exist in your target region. Fresh AWS accounts have one, so most people do not need to do anything here.
- Your credentials need to be in place locally, at
%USERPROFILE%\.aws\credentialson Windows or$HOME/.aws/credentialson macOS and Linux. - The AMI ID used below has to exist in your region. AMI IDs are region-specific and get deregistered over time, so swap in a current one if
terraform plancomplains. Looking AMIs up dynamically is a topic for a later example. - This example creates a real EC2 instance and a real security group, so it costs money until you destroy it.
Splitting the config across files
The first example used one file. Here the configuration is split into three:
terraform-manifests/
├── c1-versions.tf # terraform block + provider block
├── c2-ec2instance.tf # the EC2 instance resource
├── c3-securitygroup.tf # the security group resource
└── webserver-install.sh # bootstrap script fed into user_data
Terraform loads every .tf file in the directory and treats them as one configuration. File names and the order they load in mean nothing to Terraform, so the c1-, c2-, c3- prefixes are purely a naming convention that keeps related things next to each other in the editor. You could paste all three files back into one and get an identical plan.
The terraform settings block
This block configures Terraform itself. It is evaluated before anything else runs, which is why it cannot use variables. Everything inside it has to be a literal value.
# Terraform Settings Block
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Pin to a major version for reproducible plans
}
}
}
required_versionsays which Terraform CLI versions are allowed to run this config. If the local CLI does not satisfy it, Terraform refuses to run rather than producing a plan that might behave differently than you expect.sourcesays where to download the provider from.hashicorp/awsis shorthand for the full registry addressregistry.terraform.io/hashicorp/aws.versionis a constraint rather than an exact version, and the operators are worth memorizing.
| Constraint | Meaning | Matches |
|---|---|---|
= 5.100.0 |
exactly this version | 5.100.0 only |
>= 1.6.0 |
this version or newer | 1.6.0, 1.9.2, 2.0.0 |
~> 5.0 |
pessimistic, the rightmost part may increase | 5.1, 5.100, but not 6.0 |
~> 5.100.0 |
same rule one level deeper | 5.100.4, but not 5.101.0 |
>= 5.0, < 6.0 |
two constraints combined | same effect as ~> 5.0 |
~> is the one you will reach for most of the time. You keep getting bug fixes and new features automatically, while a breaking major release can never sneak in on its own.
The constraint only says what is allowed. What was actually selected the first time you ran terraform init gets recorded in .terraform.lock.hcl, and that file is what makes a teammate's init resolve to the same provider version as yours. Commit it.
# See which provider versions are currently selected
terraform providers
The provider block
Where the terraform block configures Terraform, the provider block configures the plugin. For AWS that means the region to work in and which credentials to use.
# Provider Block
provider "aws" {
profile = "default" # AWS Credentials Profile configured on your local machine
region = "us-east-1"
}
The AWS provider looks for credentials in a fixed order and uses the first thing it finds:
- Arguments in the provider block itself, meaning
access_keyandsecret_key. Do not do this, it puts secrets into source control. - Environment variables such as
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEYandAWS_REGION. - The shared credentials file, selected by whatever
profileyou named. - An IAM role attached to whatever it is running on, such as an EC2 instance, an ECS task, or a CI runner.
Two commands worth knowing when something feels off, because a surprising amount of AWS trouble turns out to be Terraform using an account you did not expect:
# Verify which identity Terraform will use
aws sts get-caller-identity
# List the profiles available on this machine
aws configure list-profiles
I have written profile = "default" explicitly here because it makes the example easier to follow. Real projects usually leave it out, so the same config works both on your machine and in CI, where credentials arrive as environment variables or through an IAM role.
The resource block
# Resource Block: EC2 Instance
resource "aws_instance" "ec2demo" {
ami = "ami-0b826bb6d96d2afe4" # Amazon Linux in us-east-1, update as per your region
instance_type = "t3.micro"
user_data = file("${path.module}/webserver-install.sh")
# Referencing the security group's id creates an IMPLICIT DEPENDENCY:
# Terraform creates the security group first, and destroys it last.
vpc_security_group_ids = [aws_security_group.web_sg.id]
tags = {
Name = "EC2 Settings-Providers-Resources Demo"
}
}
The header line has three parts, and it helps to name them properly once so the docs stop feeling cryptic.
resource "aws_instance" "ec2demo" {
^ ^ ^
| | +-- LOCAL NAME
| +---------------- RESOURCE TYPE
+---------------------------- BLOCK TYPE
- The block type says what kind of block this is, in this case a
resource. - The resource type is defined by the provider, and its prefix decides which provider handles it. Anything starting with
aws_goes to the AWS provider. - The local name is your own label, used only for referring to this resource elsewhere in your configuration.
Put the last two together and you get the resource address, aws_instance.ec2demo. That address is how you refer to this instance from other blocks, in terraform state commands, and in -target flags. The local name only has to be unique per resource type, and it never shows up in AWS.
Arguments and attributes
This distinction confused me at first, and it turns out to matter later:
- Arguments are what you set going in, such as
ami,instance_type,user_dataandtags. Some are required and some are optional, and the provider docs list them under Argument Reference. - Attributes are what AWS hands back after creation, such as
id,public_ip,arnandprivate_dns. You read them asaws_instance.ec2demo.public_ip, and they are listed under Attribute Reference.
Referencing another resource's attribute is also what creates a dependency between resources, which is the whole point of the security group further down.
The file() function and path.module
user_data = file("${path.module}/webserver-install.sh")
file(path) reads a file from disk at plan time and returns its contents as a string. The file has to exist when you run terraform plan, otherwise the plan fails.
path.module is the filesystem path of the directory holding the current .tf file. Using it instead of a bare "webserver-install.sh" means the config keeps working if this directory is later turned into a reusable module and called from somewhere else. The two related values are path.root, the top-level working directory, and path.cwd, wherever you happened to run terraform from.
Once the script needs values injected into it, templatefile() is the natural next step, since it does the same job but substitutes variables before returning the string.
What user_data actually is
user_data is not a Terraform feature. Terraform just hands the string to AWS, and cloud-init runs it as root, once, on the instance's first boot. Three consequences are worth internalizing early:
- The script runs after
terraform applyreports success. Apply finishing means the instance exists, not that nginx is serving anything yet, so give it a minute. - Changing
user_dataon an existing instance forces a replacement by default, precisely because it only ever runs on first boot. Watch for# forces replacementin the plan output. - Nothing from the script is echoed back to your terminal. To debug it you SSH in and read
/var/log/cloud-init-output.log.
A second resource: the security group
An EC2 instance launched into the default VPC lands in the default security group, which allows no inbound traffic from the internet. Installing nginx is not enough on its own, because without a rule opening port 80 the page simply times out. So the rule becomes a second resource.
# Resource Block: Security Group allowing inbound HTTP from the internet
resource "aws_security_group" "web_sg" {
name = "terraform-demo-web-sg"
description = "Allow inbound HTTP traffic and all outbound traffic"
# vpc_id is omitted, so this security group is created in the region's default VPC
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1" # -1 means all protocols; from_port/to_port must be 0
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "terraform-demo-web-sg"
}
}
Reading those rules properly is worth the couple of minutes:
ingressis inbound traffic.from_portandto_portdescribe a port range, not a source and a destination, which is why both are80here. For SSH it would be22to22, and for ephemeral ports1024to65535.protocol = "-1"means all protocols, and it requiresfrom_portandto_portto be0. When you want TCP specifically, you name it, as the ingress rule does.cidr_blocks = ["0.0.0.0/0"]means any IPv4 address. That is correct for a public web server, but do not copy it onto an SSH rule, where you want your own IP scoped down tox.x.x.x/32.- Egress has to be written out explicitly. The AWS default security group allows all outbound traffic, but a security group you create through Terraform gets no egress rule unless you add one, and without it the instance cannot reach the package repositories, so the nginx install quietly fails.
- Security groups are stateful, so reply traffic for an allowed inbound request is permitted automatically. You do not need a matching outbound rule for every inbound rule.
Notice there is no rule for port 22. This example creates no key pair, so there would be nothing to SSH in with anyway.
One thing to be aware of as configs grow: inline ingress and egress blocks keep the whole rule set readable in one place, which is why they are used here. Production configs often prefer the standalone aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources instead. With inline blocks Terraform manages the rule set as a single unit and will revert anything added out of band, while separate rule resources can be added and removed individually without churning the whole group.
Implicit dependencies, the good part
The only thing tying these two resources together is one line back in c2-ec2instance.tf:
vpc_security_group_ids = [aws_security_group.web_sg.id]
id is an attribute, so it does not exist until AWS has actually created the security group. Terraform spots that reference while building its dependency graph and works out the ordering by itself:
create: aws_security_group.web_sg -> aws_instance.ec2demo
destroy: aws_instance.ec2demo -> aws_security_group.web_sg
The destroy order is the exact reverse of the create order. You never wrote depends_on, and you should not have to. depends_on is the escape hatch for dependencies Terraform genuinely cannot see, for example an application that needs an IAM policy to exist even though nothing in its configuration references that policy. Wherever a reference already exists, the implicit dependency does the job.
The reverse ordering on destroy matters just as much, because AWS refuses to delete a security group that is still attached to a running instance, and Terraform gets that right without being told.
You can print the graph Terraform builds:
terraform graph
In the plan output, the tell is (known after apply) next to the vpc_security_group_ids value. That is Terraform saying it cannot know the id yet because the security group does not exist.
The bootstrap script
#!/bin/bash
# EC2 user data: runs once as root on first boot
dnf update -y
dnf install -y nginx
systemctl enable nginx
systemctl start nginx
echo "<h1>Welcome to Terraform Demo - Settings, Providers & Resources</h1>" > /usr/share/nginx/html/index.html
mkdir -p /usr/share/nginx/html/metadata
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/dynamic/instance-identity/document -o /usr/share/nginx/html/metadata/instance-identity.html
Three small details in there are easy to trip over:
dnfis the package manager on Amazon Linux 2023. Older Amazon Linux 2 examples useyum, so match this to whichever AMI you picked.- There is no
sudoanywhere, because user data already runs as root. 169.254.169.254is the Instance Metadata Service, a link-local address that is only reachable from inside the instance. Recent AMIs enforce IMDSv2, which is why the script fetches a token first and then sends it as a header. A plaincurlwithout the token comes back with a401.
Running it
terraform init # downloads the AWS provider, writes .terraform/ and .terraform.lock.hcl
terraform validate # checks syntax and internal consistency, makes no AWS calls
terraform fmt # rewrites .tf files into canonical style
terraform plan # shows what will be created, changed, or destroyed
terraform apply # creates the security group, then the instance
There are a few things worth actually stopping to look at rather than scrolling past:
- After
init, a local backend is initialized, the provider plugin is downloaded into.terraform/providers/, and.terraform.lock.hclappears with the exact provider version that was chosen. - After
plan, the summary reads2 to add, 0 to change, 0 to destroy, andvpc_security_group_idson the instance shows(known after apply). - After
apply,terraform.tfstateexists, and the log showsaws_security_group.web_sg: Creation completearriving beforeaws_instance.ec2demo: Creating..., with nodepends_onanywhere in the config.
Port 80 is open now, so there is no manual console step. Grab the public IP from the AWS Console under EC2, or read it straight out of state:
terraform state show aws_instance.ec2demo
Then open http://<PUBLIC-IP>/ in a browser, and http://<PUBLIC-IP>/metadata/instance-identity.html for the metadata the script saved. If it times out on the first try, wait a minute for user data to finish, as described above.
A first look at state
terraform.tfstate is the mapping between your configuration and the real objects in AWS. Without it Terraform has no idea that aws_instance.ec2demo refers to i-0abc123.... There are three separate things people call "state", and keeping them apart makes plan output much easier to read:
| Term | What it is |
|---|---|
| Desired state | What your .tf files say should exist |
| State file | What Terraform believes exists, recorded at the last apply |
| Real state | What actually exists in AWS right now |
terraform plan refreshes the state file against reality and then diffs it against your configuration, so the plan output is exactly that difference.
# List every resource tracked in state
terraform state list
# Show every attribute of one resource, including ones you never set
terraform state show aws_instance.ec2demo
# Dump the whole state as JSON
terraform show -json
Two habits worth building from the start. Never hand-edit terraform.tfstate, use the terraform state subcommands instead. And treat the state file as a secret, because it stores values in plaintext, so keep it out of Git. Remote backends with encryption are the real answer to that, and they come later.
Cleaning up
terraform plan -destroy # preview what a destroy would remove
terraform destroy # instance first, then the security group
Watch the ordering in the destroy log, because aws_instance.ec2demo goes before aws_security_group.web_sg, the exact reverse of creation.
Then remove the local working files, keeping .terraform.lock.hcl committed.
Remove-Item -Recurse -Force .terraform
Remove-Item -Force terraform.tfstate*
The macOS and Linux equivalent is the same idea.
rm -rf .terraform
rm -rf terraform.tfstate*
Revision checklist
This is the section I expect to come back to, so here is everything above condensed.
- terraform block holds settings for Terraform itself,
required_versionplusrequired_providers. Literals only, no variables allowed. - Version constraints:
~> 5.0permits any5.xbut not6.0. The constraint says what is allowed,.terraform.lock.hclrecords what was chosen, and the lock file gets committed. - provider block configures the plugin, mainly region and profile. Credentials resolve in the order block arguments, environment variables, shared credentials file, IAM role. Never hardcode keys.
- resource block is
resource "<TYPE>" "<LOCAL_NAME>", addressed asTYPE.LOCAL_NAME. The type prefix,aws_here, decides which provider handles it. - Arguments go in, attributes come back out. Referencing another resource's attribute is what creates an implicit dependency.
- Implicit dependencies:
vpc_security_group_ids = [aws_security_group.web_sg.id]is the entire mechanism. Terraform reads the reference, builds a graph, creates in dependency order and destroys in reverse.depends_onexists only for dependencies Terraform cannot see. - Security groups:
from_portandto_portare a port range and not a source and destination,protocol = "-1"means all protocols and requires ports of0, they are stateful so replies are allowed automatically, and a Terraform-created group has no egress rule until you write one. - file() and path.module read a file from disk at plan time, relative to the directory holding the
.tffile rather than your shell's current directory. - user_data runs once as root on first boot through cloud-init, changing it forces instance replacement, and you debug it at
/var/log/cloud-init-output.log. - Multiple .tf files are concatenated by Terraform. Names and ordering exist for humans only.
- State comes in three flavours, desired, recorded and real. Inspect it with
terraform state listandterraform state show, never edit it by hand, and treat it as containing secrets.
What this example still does not do
Listing the gaps is useful, because each one is roughly the next example in the series:
- The AMI ID is hardcoded, which a data source fixes.
- There is no key pair, so SSH is impossible until the
key_nameargument shows up. - Region, ports and instance type are hardcoded everywhere, which is what input variables and
terraform.tfvarsare for. - The public IP is not printed after apply, which outputs solve.
- There is only ever one instance, until the
countandfor_eachmeta-arguments arrive. - Repeating the same
ingressblock for many ports begs fordynamicblocks. - State sits locally on one machine, which remote backends with locking fix.