08 · A First Real Resource¶
This module walks through a complete, small configuration end to end — a local file resource (needs no cloud account at all) and then the cloud equivalent (an S3 bucket), reasoned through against documented provider behavior. Neither example is run against a live cloud account in this lesson — treat the AWS portion as a careful read-through to prepare you for your own account, and verify current argument names against the provider's registry page before applying anything for real.
Starting with zero cloud dependencies: the local provider¶
The hashicorp/local provider manages files on the machine running
Terraform — perfect for learning the full resource lifecycle without
needing any credentials:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "greeting" {
filename = "${path.module}/greeting.txt"
content = "Hello from Terraform!\n"
}
path.module is a built-in reference to the filesystem directory
containing the current module's .tf files — using it instead of a bare
relative path keeps the configuration correct regardless of what directory
terraform is invoked from.
Running this (terraform init then terraform apply) genuinely creates a
greeting.txt file with that content, tracked in state exactly like a cloud
resource would be — terraform destroy deletes the file again. It's a real,
safe way to see the full workflow before touching billable infrastructure.
Reading it back with a data source¶
data "local_file" "greeting_check" {
filename = local_file.greeting.filename
}
output "file_contents" {
value = data.local_file.greeting_check.content
}
This deliberately reuses module 05's resource/data-source distinction: the
resource block owns and can destroy the file; the data block only reads
whatever is there, tracking it via an implicit dependency (Terraform knows
to create the file before trying to read it, because the data block
references local_file.greeting.filename).
The cloud equivalent: an S3 bucket, reasoned through¶
Structurally, an S3 bucket resource follows the exact same shape as the
local_file example — a resource block, arguments, and attributes you can
reference elsewhere:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "reports" {
bucket = "acme-reports-2026-${random_id.suffix.hex}"
tags = {
Environment = "learning"
ManagedBy = "terraform"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
Two things worth calling out, reasoned from documented AWS/Terraform behavior rather than a live run:
- S3 bucket names are globally unique across all of AWS, not just your
account — a hardcoded name like
acme-reports-2026will very likely already be taken by someone else. Appending arandom_idresource's.hexattribute is a common, documented pattern for guaranteeing uniqueness without hand-picking a name. - The
aws_s3_bucketresource on modern provider versions (v4+) intentionally keeps a minimal set of arguments (bucket,bucket_prefix,tags,force_destroy) — versioning, encryption, lifecycle rules, and public access blocking are each configured through separate resources (as seen in module 05'saws_s3_bucket_versioningexample) rather than nested arguments on the bucket itself. Always check the provider's current registry page, since AWS provider major versions have changed this shape before.
Blocking public access (a documented, commonly-required companion resource)¶
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
This is worth including even in a learning exercise: several cloud
providers' documented security baselines call for explicitly blocking
public access on storage resources rather than relying on defaults, and
seeing the pattern here (a second resource block, referencing the first via
aws_s3_bucket.reports.id) reinforces module 05's dependency-inference
point.
Reading the plan output shape¶
If this were applied, terraform plan output would show, per documented
Terraform conventions:
Terraform will perform the following actions:
# random_id.suffix will be created
+ resource "random_id" "suffix" {
+ byte_length = 4
+ hex = (known after apply)
+ id = (known after apply)
}
# aws_s3_bucket.reports will be created
+ resource "aws_s3_bucket" "reports" {
+ bucket = (known after apply)
+ arn = (known after apply)
+ id = (known after apply)
...
}
# aws_s3_bucket_public_access_block.reports will be created
+ resource "aws_s3_bucket_public_access_block" "reports" {
+ bucket = (known after apply)
+ block_public_acls = true
...
}
Plan: 3 to add, 0 to change, 0 to destroy.
Note bucket = (known after apply) on aws_s3_bucket.reports — even though
part of its value (acme-reports-2026-) is a literal string you wrote, the
whole expression depends on random_id.suffix.hex, which isn't known
until random_id.suffix is actually created, so Terraform can't resolve the
full string at plan time.
How It Actually Works: computed attributes and the "known after apply" placeholder¶
Everything below is reasoned through from the local and (hypothetical)
aws provider's documented schema behavior — no real cloud call was made to
produce these lessons.
- Every attribute in a provider's schema is tagged
Required,Optional, orComputed(and some bothOptionalandComputed).bucketonaws_s3_bucketis one you supply;arnis purelyComputed— the provider fills it in only after the realCreateBucketAPI call returns an ARN that AWS assigned. - This is exactly what "(known after apply)" means during
plan. Terraform Core cannot show a real value for aComputed-only attribute before the resource exists, soPlanResourceChangereturns an explicit "unknown" marker for that attribute rather than a guessed value. Any downstream expression that references that unknown value (another resource's argument built fromaws_s3_bucket.reports.arn) propagates the "unknown" marker forward through the graph, which is why referencing an unknown attribute forces that downstream resource to also show as "known after apply" instead of a concrete diff — the unknown-ness is contagious through the dependency graph until a real apply resolves it. - The
localprovider has no real API, which makes it a useful reasoning tool. ItsApplyResourceChangeimplementation forlocal_filejust performs a Go filesystem write — there's no network RPC to an external service, no eventual consistency delay, and no partial-failure window, which is precisely what makes it good for isolating "how does Terraform's graph and diff logic behave" from "how does a specific cloud API behave." aws_s3_bucket_public_access_blockexisting as a companion resource (not an argument on the bucket resource) is itself a design signal: AWS models public-access blocking as a separate API object with its own lifecycle, so the provider schema mirrors that as a separate resource type rather than folding it intoaws_s3_bucket's own arguments — the provider's resource boundaries generally track the cloud API's own object boundaries, not an abstraction Terraform invents.
Exercise¶
Write out the full local_file example from this module (resource + data
source + output) in a scratch directory, run terraform init and
terraform apply for real (it's local-only, no account needed), inspect
greeting.txt on disk, then run terraform destroy and confirm the file is
gone. Separately, without applying it, write the S3 bucket + public-access-block
pair above from memory and explain in one sentence why bucket shows as
(known after apply) in the plan.