Provider, Your First Resource, and the init plan apply destroy Lifecycle

K
Kai··9 min read

Last article we installed Terraform and grasped that it consists of a core and a separate provider, operating through the write → plan → apply lifecycle. Now it's time to type real commands. We'll declare the AWS provider, create an S3 bucket, then walk the full lifecycle and stop at each step to see what actually happens. A bucket is the ideal resource for a first article: creating it is free, nothing runs in the background to cost money, and it destroys in a second.

Goal

Understand the four commands init, plan, apply, destroy not by definition but by observing the real output of each. Also take a first look at the state file to see what Terraform remembers.

💰 Cost

Creating an S3 bucket is free. You only pay for the data stored in it and the request volume, and this article puts nothing into the bucket. The actual total cost is near zero, and we destroy it as soon as we're done.

Declare the provider and pin the version

Create an empty directory, and in it a main.tf file:

terraform {
  required_version = ">= 1.10"

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

provider "aws" {
  region = "ap-southeast-1"
}

The terraform {} block declares constraints about the runtime environment. required_version says this configuration needs Terraform 1.10 or newer — anyone on an older version is blocked immediately instead of hitting a confusing error midway. required_providers declares which provider is needed, where it comes from (hashicorp/aws on the Terraform Registry), and which version.

The ~> 6.0 constraint is worth pausing on. This is the "pessimistic" operator, allowing any latest 6.x release but not jumping to 7.0. The intent: accept patches and minor features in the 6 line, but don't automatically jump to a new major version (which may change syntax in breaking ways). Pinning the provider version is a good habit from day one, because your infrastructure must be reproducible even six months later.

The provider "aws" block configures that provider itself. Here we only need region. We don't write credentials into the file — that's something you should never do. The AWS provider finds credentials in a priority order: parameters in the provider block, then environment variables (AWS_ACCESS_KEY_ID...), then the ~/.aws/credentials and ~/.aws/config files, then container or instance profile credentials. Since this machine already has the default profile configured via aws configure, the provider uses it without us declaring anything more.

terraform init: preparing the working directory

$ terraform init
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.46.0...
- Installed hashicorp/aws v6.46.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

init reads required_providers, finds the latest release matching ~> 6.0 (which is 6.46.0), downloads the provider binary into the .terraform/ directory inside the working directory, and verifies HashiCorp's signature. That's the provider process Article 1 mentioned: core will launch it as a child process and talk over gRPC.

An important byproduct: the .terraform.lock.hcl file. It records the exact provider version selected along with its checksum. On the next init, even if a newer 6.47 exists, Terraform still uses exactly the version recorded in the lock file. This is a locking mechanism like npm's package-lock.json — and just as the message advises, you should commit it to git so the whole team and CI use the same version. By contrast, the .terraform/ directory holding the downloaded binary is not committed (it's in .gitignore).

Before moving on, two quick checks that cost nothing:

$ terraform fmt -check
$ terraform validate
Success! The configuration is valid.

fmt standardizes formatting (indentation, aligning the = signs); add -check to only check without modifying. validate checks HCL syntax and parameter consistency without calling any API — catching a misspelled field name before spending a round-trip to AWS.

Declare the resource and read the plan

Add an S3 bucket to main.tf:

resource "aws_s3_bucket" "first" {
  bucket_prefix = "tf-series-bai2-"
  force_destroy = true

  tags = {
    Project = "terraform-series"
    Bai     = "02"
  }
}

output "bucket_name" {
  value = aws_s3_bucket.first.id
}

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

A resource block has two labels: the type (aws_s3_bucket) and the local name (first). The local name is how you reference this resource elsewhere in the configuration (aws_s3_bucket.first.arn); it is not the bucket's name on AWS. The real bucket name is determined by bucket_prefix — we let AWS append a random suffix because S3 bucket names must be globally unique, and a hardcoded name easily collides with someone else's. force_destroy = true allows destroying the bucket even when it still has objects inside; only turn this on in a lab.

Now see what Terraform intends to do:

$ terraform plan

Terraform will perform the following actions:

  # aws_s3_bucket.first will be created
  + resource "aws_s3_bucket" "first" {
      + arn                         = (known after apply)
      + bucket                      = (known after apply)
      + bucket_domain_name          = (known after apply)
      + bucket_prefix               = "tf-series-bai2-"
      + force_destroy               = true
      + id                          = (known after apply)
      + region                      = "ap-southeast-1"
      + tags                        = {
          + "Bai"     = "02"
          + "Project" = "terraform-series"
        }
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + bucket_arn  = (known after apply)
  + bucket_name = (known after apply)

The + at the start of each line means "will be created." The summary line Plan: 1 to add, 0 to change, 0 to destroy is the thing you always read first on any plan — it tells you the scale of the change before you scan the detail.

Notice that many fields read (known after apply). Those are values Terraform doesn't yet know at plan time because AWS generates them after creation: the final bucket name (because of the random suffix), the ARN, the domain name. Terraform marks them as "known after apply" instead of guessing. Meanwhile, the things you wrote explicitly in the configuration (bucket_prefix, region, tags) show their values immediately. This distinction matters when reading the plan of large infrastructure: which fields are set, which are left open.

Note the last line of the plan: because the plan isn't saved to a file (-out), Terraform doesn't guarantee the next apply does exactly the same thing. In a lab that's fine; in CI we'll save the plan and apply exactly that plan (Part VII).

terraform apply

$ terraform apply -auto-approve
...
aws_s3_bucket.first: Creating...
aws_s3_bucket.first: Creation complete after 2s [id=tf-series-bai2-20260525025042897800000001]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

bucket_arn = "arn:aws:s3:::tf-series-bai2-20260525025042897800000001"
bucket_name = "tf-series-bai2-20260525025042897800000001"

apply re-runs the plan, then (if you don't use -auto-approve) asks for confirmation, then executes. Underneath, core sends a "create bucket" request over gRPC to the AWS provider, the provider calls the CreateBucket API, AWS returns the real bucket name tf-series-bai2-20260525025042897800000001 (the prefix plus the generated suffix). The fields that were (known after apply) now have real values, and the two outputs print the exact name and ARN.

State: what Terraform remembers

After apply, the directory has a new terraform.tfstate file. This is the crux of the article. See which resources Terraform tracks:

$ terraform state list
aws_s3_bucket.first

See the detail of one resource in cleaned-up form:

$ terraform show
# aws_s3_bucket.first:
resource "aws_s3_bucket" "first" {
    arn                         = "arn:aws:s3:::tf-series-bai2-20260525025042897800000001"
    bucket                      = "tf-series-bai2-20260525025042897800000001"
    bucket_domain_name          = "tf-series-bai2-20260525025042897800000001.s3.amazonaws.com"
    bucket_regional_domain_name = "tf-series-bai2-...s3.ap-southeast-1.amazonaws.com"
    force_destroy               = true
    hosted_zone_id              = "Z3O0J2DXBE1FTB"
    id                          = "tf-series-bai2-20260525025042897800000001"
    region                      = "ap-southeast-1"
    tags                        = {
        "Bai"     = "02"
        "Project" = "terraform-series"
    }
    ...
}

The state file itself is JSON. The first few lines show the structure:

{
  "version": 4,
  "terraform_version": "1.15.4",
  "serial": 2,
  "lineage": "d082edf2-018a-b768-b885-f1b2fe6be02e",
  "outputs": {
    "bucket_arn": { "value": "arn:aws:s3:::tf-series-bai2-2026...", "type": "string" },
    "bucket_name": { "value": "tf-series-bai2-2026...", "type": "string" }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "first",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
      "instances": [ { "schema_version": 0, "attributes": { ... } } ]
    }
  ]
}

State is the mapping between the local name in the configuration (aws_s3_bucket.first) and the real resource on AWS, along with all the attributes read back. serial increments each time state changes; lineage identifies the lineage of the state so Terraform knows whether two state files share a common origin. This is the ledger Terraform consults on each run to know "how does the thing I manage currently look."

One thing to burn into memory: this file contains every attribute of the resource, including sensitive values (RDS passwords, private keys) in plaintext. That's why it must absolutely never be committed to git, and why Part II devotes several articles to keeping state in a safe place (remote state on S3) and handling secrets.

You can verify the bucket really exists, independently of Terraform, with the AWS CLI:

$ aws s3api head-bucket --bucket tf-series-bai2-20260525025042897800000001
{
    "BucketArn": "arn:aws:s3:::tf-series-bai2-20260525025042897800000001",
    "BucketRegion": "ap-southeast-1",
    "AccessPointAlias": false
}

Why a second apply creates no extra bucket

This is the most important property of the declarative model. Run plan again without changing anything:

$ terraform plan
aws_s3_bucket.first: Refreshing state... [id=tf-series-bai2-20260525025042897800000001]

No changes. Your infrastructure matches the configuration.

Terraform doesn't create a second bucket. The mechanism: before computing the plan, it refreshes — reads state to get the id tf-series-bai2-2026..., asks AWS what that bucket currently looks like, then compares three things: the configuration you wrote (desired), state (what Terraform last knew), and reality on AWS. All three match, so the diff is empty, and the result is "No changes." This is idempotence: running any number of times with the same configuration yields the same state. That same comparison mechanism also detects drift — if someone tweaks the bucket by hand on the console, the next plan will see reality deviating from the configuration and propose pulling it back.

🧹 Cleanup

$ terraform destroy -auto-approve
...
Plan: 0 to add, 0 to change, 1 to destroy.

aws_s3_bucket.first: Destroying... [id=tf-series-bai2-20260525025042897800000001]
aws_s3_bucket.first: Destruction complete after 1s

Destroy complete! Resources: 1 destroyed.

destroy reads state, calls the API to delete each resource it manages, then updates state. The outputs also turn to null because the resource that produced them is gone. Always run destroy after you finish practicing so you don't leave junk on the account.

Wrap-up

The four commands now have concrete shape: init downloads the provider and creates the lock file, plan compares configuration against state to preview the diff (with (known after apply) for values AWS generates later), apply executes over gRPC to the provider and then records the result into state, destroy tears down. State is the ledger mapping configuration to real resources, and it contains sensitive values too, so it must be kept carefully. Idempotence comes from Terraform refreshing and doing a three-way comparison before each plan.

We've written HCL by imitation without really understanding the syntax. Next article dissects HCL properly: blocks, arguments, data types, expressions, and what else the terraform {} block can declare beyond required_providers.