Providers, Resources & State
A complete provider and resource example, terraform plan/apply/destroy, and why the state file matters.
A complete example configuration
Here is a full, working Terraform file that configures the AWS provider and creates an S3 bucket:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "app_backups" {
bucket = "myapp-prod-backups"
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "app_backups" {
bucket = aws_s3_bucket.app_backups.id
versioning_configuration {
status = "Enabled"
}
}
Providers
The provider block configures which cloud (or other system) Terraform talks to, and how — here, the AWS provider, targeting the us-east-1 region. The required_providers block pins which provider plugin and version to download on terraform init. Providers are what make Terraform generic: the exact same core workflow (init/plan/apply) works whether the provider is AWS, Azure, GCP, Kubernetes, or something unrelated to infrastructure entirely, like a GitHub or Datadog provider that manages repository settings or monitors as code.
Resources
A resource block declares one specific piece of infrastructure to create and manage. Its general shape is:
resource "<PROVIDER_TYPE>" "<LOCAL_NAME>" {
# configuration arguments specific to this resource type
}
aws_s3_bucket.app_backups — the resource type (aws_s3_bucket) tells Terraform (via the AWS provider) exactly which API calls to make; app_backups is a name local to this Terraform config, used to reference the resource elsewhere (as seen in the aws_s3_bucket_versioning block above, which references aws_s3_bucket.app_backups.id).
terraform plan, apply, and destroy
terraform plan — compares your configuration against the current real state and prints exactly what it would change, without changing anything yet:
terraform plan
Terraform will perform the following actions:
# aws_s3_bucket.app_backups will be created
+ resource "aws_s3_bucket" "app_backups" {
+ bucket = "myapp-prod-backups"
+ id = (known after apply)
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
This is the step that makes infrastructure changes reviewable — the output reads like a diff, and can be posted in a pull request for a teammate to check before anyone applies it for real.
terraform apply — runs the plan again and, after you confirm (yes), actually makes the API calls to create/update/destroy resources so reality matches your configuration:
terraform apply
terraform destroy — the reverse: tears down every resource Terraform manages in this configuration.
terraform destroy
This is genuinely destructive — it's most commonly used to clean up a short-lived environment (a PR preview environment, a test sandbox) rather than run against production.
The state file
Every time you run apply, Terraform records what it created in a state file (terraform.tfstate by default) — a JSON file mapping each resource in your config to the real-world object it corresponds to (e.g., which exact AWS S3 bucket ID belongs to aws_s3_bucket.app_backups).
The state file is what makes plan possible at all: without it, Terraform would have no way to know what already exists versus what your config additionally describes, and every apply would try to recreate everything from scratch. Comparing config -> state -> real infrastructure is the entire mechanism by which Terraform decides what to create, change, or destroy.
Why the state file must be handled carefully
- It can contain sensitive data — resource attributes like database passwords or generated secrets can end up in plain text in the state file.
- It must not be edited by hand in normal operation — a manually broken state file can make Terraform believe a resource exists (or doesn't) when reality disagrees, requiring manual repair (
terraform statesubcommands, or in the worst case,import/re-creation). - It cannot safely be shared via a plain local file when more than one person (or one CI pipeline) applies changes — two people running
applyagainst the same local state file at the same time can corrupt it or silently overwrite each other's changes.
Remote state backends
For any real team (more than one person, or a CI pipeline that also runs apply), state should live in a remote backend rather than a local file — commonly an S3 bucket (with DynamoDB for locking, so two applies can't run concurrently against the same state) or Terraform Cloud:
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
This gives everyone on the team (and CI) a single, shared, locked source of truth for what's actually been applied — the next page covers organizing configuration further with variables and modules.
Common mistakes
- Committing
terraform.tfstateto git — it can contain secrets, and a locally-committed state file defeats remote locking entirely. - Running
applywithout reading theplanoutput first — especially a plan that shows unexpected destroys, which usually means a resource was renamed or its configuration changed in a way Terraform reads as "delete and recreate." - Two people applying from local state at the same time, corrupting or silently overwriting each other's changes — this is exactly what a remote backend with locking prevents.
- Manually editing the state file instead of using
terraform statesubcommands orterraform importfor the rare cases that need it.