Terraform Resources: resource Block with Examples

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
kreuzwerker/docker 3.9.0
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform and Docker — Terraform lab environment on Ubuntu
Privilege Normal user (Docker socket access required)
Scope Terraform resource block anatomy — syntax, type, local name, arguments, attributes, addresses, references, create, in-place update, replacement, deletion by removing configuration, provider-backed Docker verification, meta-arguments overview, schema reading, and common resource errors. Does not cover data sources in depth, count, for_each, lifecycle rules, dependency graphs, import, moved or removed blocks, or provider installation.
Related guides Terraform HCL syntax
terraform plan command
terraform apply command
Terraform providers
Terraform Associate certification course

A Terraform resource is how you tell Terraform to own an infrastructure object. Every managed object starts as a resource block in your configuration. Terraform stores instances in state, talks to a provider to create or change real objects, and plans deletions when you remove the block.

This guide focuses on the resource block itself — not a full configuration tour. You begin with the built-in terraform_data resource for syntax and lifecycle fundamentals, then add a Docker container you can verify with docker ps outside Terraform.

hcl
resource "terraform_data" "example" {
  input = "Hello Terraform"
}

The type is terraform_data, the local name is example, and input is a configuration argument. The sections below unpack resource anatomy, then walk through create, update, replacement, and delete with real plan output.

NOTE
Work in ~/terraform-labs/terraform-resource/ on the Terraform lab environment on Ubuntu. Run terraform init in each new directory before your first plan. For block and expression basics, see Terraform HCL syntax.

What is a Terraform resource?

Terraform resources represent infrastructure objects Terraform manages for you. A resource block declares a resource, which can produce one or multiple resource instances depending on whether count or for_each is used — a file, a container, a network, a cloud instance, a DNS record, or another provider-defined type.

Resources fall into two broad families:

  • Provider-backed resources — defined by a provider plugin (docker_container, aws_instance, azurerm_resource_group). The provider translates Terraform operations into API calls.
  • Built-in Terraform resourceterraform_data is provided by Terraform's built-in provider and does not require installing an external provider plugin. It helps you practice language behavior without downloading a third-party provider.

When you run terraform apply, Terraform compares configuration and state, builds a graph of resource instances, and asks providers to create, update, or destroy remote objects. Removing a resource block from configuration tells Terraform the object should no longer exist — the next plan proposes deletion.

Resource block syntax

Every managed resource follows the same header shape:

hcl
resource "<TYPE>" "<NAME>" {
  # arguments
}
Piece Role
resource Keyword that starts a managed resource block
<TYPE> Provider-defined resource type (terraform_data, docker_container, …)
<NAME> Local label you choose — unique per type in the module
Block body Arguments the provider schema accepts for that type

Resource type and local name

The resource type (terraform_data, docker_container) selects which provider schema Terraform uses. The local name (example, web) is a label you pick inside the module — it does not have to match the remote object's name in Docker or AWS. Together, type and local name form the prefix of every resource address (docker_container.web).

Arguments, attributes, addresses, and references

Arguments are keys you set in the resource block. They describe what you want the object to look like:

hcl
input = "web"

Attributes are values the provider publishes after the object exists. You read them in expressions but do not assign them inside the resource block:

hcl
terraform_data.example.output

For terraform_data, the output attribute is computed from input after apply — which is why the root output block in this lab prints Hello Terraform once the resource exists. On other types, attributes such as docker_container.web.id come from the provider schema. Configuration arguments go in the block body; exported attributes appear only in references. For general HCL expression rules, see Terraform HCL syntax.

A resource address identifies a Terraform resource or resource instance inside a module:

text
<resource_type>.<local_name>              # single instance
terraform_data.example[0]                 # count index
terraform_data.example["web"]             # for_each key

For a single-instance resource in this lab, terraform state list prints terraform_data.example. Indexed forms appear when count or for_each creates multiple instances — this guide uses one instance only, but you should recognize indexed addresses in plans and state lists. Full count and for_each workflows belong in dedicated lessons.

References connect blocks in the same module:

hcl
output "message" {
  value = terraform_data.example.output
}

terraform_data.example.output references the output attribute of that resource instance. Terraform also infers a dependency from the reference. If the value is not yet known during planning — as with (known after apply) on a first create — Terraform carries it as an unknown value until apply provides it. When one resource argument depends on another resource's attribute — for example image = docker_image.nginx.image_id — Terraform builds an implicit dependency. Graph ordering and explicit depends_on are covered in a dependencies lesson.


Create a Terraform resource

The core workflow is the same for built-in and provider-backed types: write configuration, initialize, validate, plan, apply, then confirm state.

Create terraform_data

Start with terraform_data only. It needs no external provider download and is ideal for learning addresses and the standard workflow.

Create the lab directory:

bash
mkdir -p ~/terraform-labs/terraform-resource

Move into it — every command below assumes you are here:

bash
cd ~/terraform-labs/terraform-resource

Write the root module:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "example" {
  input = "Hello Terraform"
}

output "message" {
  value = terraform_data.example.output
}
EOF

Inspect plan, apply, and state

Initialize the working directory so Terraform can evaluate the configuration:

bash
terraform init

Sample output (trimmed):

output
Terraform has been successfully initialized!

Check that the configuration is syntactically valid:

bash
terraform validate

Sample output:

output
Success! The configuration is valid.

Preview what Terraform will create:

bash
terraform plan

Sample output (trimmed):

output
# terraform_data.example will be created
  + resource "terraform_data" "example" {
      + id     = (known after apply)
      + input  = "Hello Terraform"
      + output = (known after apply)
    }

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

The + symbol marks a new resource instance. Note that output is (known after apply) on the first plan — the reference in the root output block is valid, but the computed value is not available until apply runs. Apply the plan when it matches your intent:

bash
terraform apply -auto-approve

Sample output (trimmed):

output
terraform_data.example: Creating...
terraform_data.example: Creation complete after 0s [id=39ef261d-9ff1-4864-1e63-08531f2ea7e2]

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

Outputs:

message = "Hello Terraform"

List the address Terraform recorded in state:

bash
terraform state list

Sample output:

output
terraform_data.example

That line is the resource address for this single instance. Terraform uses it on every later plan until the instance is destroyed.

Create a provider-backed Docker resource

Provider-backed resources talk to real APIs. Add Docker objects to the same working directory so you can confirm Terraform's work outside state.

Replace main.tf with a stack that keeps terraform_data and adds an image plus container:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "terraform_data" "example" {
  input = "Hello Terraform"
}

resource "docker_image" "nginx" {
  name = "ghcr.io/nginx/nginx-unprivileged:alpine"
}

resource "docker_container" "web" {
  name  = "tf-resource-web"
  image = docker_image.nginx.image_id

  labels {
    label = "environment"
    value = "lab"
  }

  ports {
    internal = 8080
    external = 8093
  }
}

output "message" {
  value = terraform_data.example.output
}

output "container_id" {
  value = docker_container.web.id
}
EOF

Download the Docker provider plugin:

bash
terraform init

Apply the expanded configuration:

bash
terraform apply -auto-approve

Sample output (trimmed):

output
docker_image.nginx: Creation complete after 14s [...]
docker_container.web: Creation complete after 1s [...]

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

Confirm Terraform created a running container Docker itself can see:

bash
docker ps --filter name=tf-resource-web

Sample output:

output
CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS                    NAMES
ffc1e8ed00c3   334d92979f15   "/docker-entrypoint.…"   2 seconds ago   Up 1 second    0.0.0.0:8093->8080/tcp   tf-resource-web

Three resource instances now sit in state (terraform_data.example, docker_image.nginx, docker_container.web), and the container row above proves the provider created a real object on the host.


Change and delete Terraform resources

Changing configuration triggers either an in-place update (~) or a replacement (-/+). Which path applies depends on the provider schema for that argument — not on a Terraform-wide rule. Deleting managed infrastructure normally means removing the resource block from configuration and letting Terraform converge state and reality.

Update in place

Change the input argument on terraform_data.example:

bash
sed -i 's/Hello Terraform/Updated input/' main.tf

Preview the change:

bash
terraform plan

Sample output (trimmed):

output
# terraform_data.example will be updated in-place
  ~ resource "terraform_data" "example" {
      ~ input  = "Hello Terraform" -> "Updated input"
        id     = "39ef261d-9ff1-4864-1e63-08531f2ea7e2"
        output = "Hello Terraform"
    }

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

The ~ symbol means Terraform will update the existing instance without destroying it. Apply when the plan looks right:

bash
terraform apply -auto-approve

Sample output (trimmed):

output
terraform_data.example: Modifying...
terraform_data.example: Modifications complete after 0s [...]

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

After apply, the output attribute updates to match the new input.

Replace a resource

Some argument changes force destroy-and-recreate. With the Docker provider, changing docker_container.web's name cannot be done in place — Terraform plans a replacement.

Edit the container name in main.tf:

bash
sed -i 's/tf-resource-web/tf-resource-web-v2/' main.tf

Review the replacement plan:

bash
terraform plan

Sample output (trimmed):

output
# docker_container.web must be replaced
-/+ resource "docker_container" "web" {
      ~ name = "tf-resource-web" -> "tf-resource-web-v2"
      ~ id   = "ffc1e8ed00c3..." -> (known after apply)
        # (other attributes omitted)
    }

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

The -/+ symbols mean destroy the old instance, then create a new one. Do not assume every changed argument behaves this way — always read the plan for the specific resource you are editing. Apply the replacement:

bash
terraform apply -auto-approve

Sample output (trimmed):

output
docker_container.web: Destroying...
docker_container.web: Destruction complete after 1s
docker_container.web: Creating...
docker_container.web: Creation complete after 0s [...]

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

Verify Docker now shows the new container name:

bash
docker ps --filter name=tf-resource-web-v2

The old tf-resource-web name should no longer appear in docker ps output.

Remove a resource from configuration

Strip the Docker resources from main.tf while keeping terraform_data:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "terraform_data" "example" {
  input = "Updated input"
}

output "message" {
  value = terraform_data.example.output
}
EOF

Preview deletions Terraform infers from the missing blocks:

bash
terraform plan

Sample output (trimmed):

output
# docker_container.web will be destroyed
  # (because docker_container.web is not in configuration)
  - resource "docker_container" "web" { ... }

  # docker_image.nginx will be destroyed
  # (because docker_image.nginx is not in configuration)
  - resource "docker_image" "nginx" { ... }

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

Apply the destroy plan:

bash
terraform apply -auto-approve

Sample output (trimmed):

output
docker_container.web: Destroying...
docker_container.web: Destruction complete after 1s
docker_image.nginx: Destroying...
docker_image.nginx: Destruction complete after 0s

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

Confirm the container is gone from Docker:

bash
docker ps -a --filter name=tf-resource-web-v2

No rows should return. Removing configuration is the everyday deletion path. Manually deleting a state entry with terraform state rm removes Terraform's tracking without guaranteeing the remote object is destroyed — see terraform state commands for imperative state edits versus normal deletion.


Resource meta-arguments and provider schema

Meta-arguments and provider schemas answer two different questions: how Terraform handles instances, and what each resource type allows.

count, for_each, depends_on, provider, lifecycle

Meta-arguments change how Terraform handles a resource block without setting provider-specific properties:

Meta-argument One-line role
depends_on Force explicit ordering when implicit references are not enough
count Create a fixed number of indexed instances (name[0], name[1], …)
for_each Create one instance per map or set key (name["key"])
provider Select a non-default provider configuration alias
lifecycle Customize create, destroy, and replacement behavior — see Terraform lifecycle for prevent_destroy, create_before_destroy, and related rules

Each meta-argument has its own lesson. This article introduces the names only so you recognize them in provider docs and plan output.

Where resource-specific behavior is defined

Supported arguments and attributes depend on the resource type and provider version. Terraform language rules — blocks, references, planning symbols — are documented on developer.hashicorp.com. Provider-specific behavior — which arguments force replacement, required fields, computed attributes — lives in the provider's registry documentation.

When a plan surprises you, check two layers:

  1. Terraform core behavior — plan symbols (+, ~, -, -/+), addresses, and references.
  2. Provider resource schema — the docker_container or aws_instance page for that provider release.

That split is what lets you learn Terraform once and apply it across many providers.


Resource vs data source

Both blocks look similar in HCL, but their roles differ:

text
resource "<TYPE>" "<NAME>" { ... }   → manage an object (create, update, delete)
data "<TYPE>" "<NAME>" { ... }       → read existing information into the module

Use resource when Terraform should own the lifecycle. Use data when you only need to look up values — an existing network, an AMI ID, a DNS zone. A full comparison and data block examples belong in the data sources lesson that follows this chapter in the course syllabus.


Common Terraform resource errors

Use a fresh subdirectory per error under ~/terraform-labs/terraform-resource-errors/ so invalid files never accumulate in the same working directory:

text
terraform-resource-errors/
├── unsupported-argument/
├── missing-argument/
├── undeclared-reference/
├── name-conflict/
└── provider-not-initialized/

Unsupported argument

Create an isolated directory and write only the failing configuration:

bash
mkdir -p ~/terraform-labs/terraform-resource-errors/unsupported-argument && cat > ~/terraform-labs/terraform-resource-errors/unsupported-argument/main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "bad" {
  input    = "test"
  not_real = true
}
EOF

Initialize and validate in that directory only:

bash
cd ~/terraform-labs/terraform-resource-errors/unsupported-argument && terraform init -input=false && terraform validate

Sample output:

output
Error: Unsupported argument

  on main.tf line 5, in resource "terraform_data" "bad":
   5:   not_real = true

An argument named "not_real" is not expected here.

Missing required argument

Use a separate directory for the Docker schema error:

bash
mkdir -p ~/terraform-labs/terraform-resource-errors/missing-argument && cat > ~/terraform-labs/terraform-resource-errors/missing-argument/main.tf <<'EOF'
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "docker_container" "web" {
  image = "ghcr.io/nginx/nginx-unprivileged:alpine"
}
EOF

Initialize and validate:

bash
cd ~/terraform-labs/terraform-resource-errors/missing-argument && terraform init -input=false && terraform validate

Sample output:

output
Error: Missing required argument

  on main.tf line 12, in resource "docker_container" "web":
  12: resource "docker_container" "web" {

The argument "name" is required, but no definition was found.

Reference to undeclared resource

Write an output that references a resource that does not exist:

bash
mkdir -p ~/terraform-labs/terraform-resource-errors/undeclared-reference && cat > ~/terraform-labs/terraform-resource-errors/undeclared-reference/main.tf <<'EOF'
output "oops" {
  value = terraform_data.missing.output
}
EOF

Validate after init:

bash
cd ~/terraform-labs/terraform-resource-errors/undeclared-reference && terraform init -input=false && terraform validate

Sample output:

output
Error: Reference to undeclared resource

  on main.tf line 2, in output "oops":
   2:   value = terraform_data.missing.output

A managed resource "terraform_data" "missing" has not been declared in the root module.

Resource already exists

When a real object already occupies a unique name Terraform expects to own, apply fails at create time. Prepare the conflict directory and configuration:

bash
mkdir -p ~/terraform-labs/terraform-resource-errors/name-conflict && cat > ~/terraform-labs/terraform-resource-errors/name-conflict/main.tf <<'EOF'
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "docker_container" "web" {
  name  = "tf-resource-conflict"
  image = "ghcr.io/nginx/nginx-unprivileged:alpine"
}
EOF

Initialize the directory:

bash
cd ~/terraform-labs/terraform-resource-errors/name-conflict && terraform init -input=false

Create the same container name outside Terraform so the provider hits a name collision:

bash
docker run -d --name tf-resource-conflict ghcr.io/nginx/nginx-unprivileged:alpine

Apply and read the provider conflict:

bash
cd ~/terraform-labs/terraform-resource-errors/name-conflict && terraform apply -auto-approve

Sample output (trimmed):

output
Error: Unable to create container: Error response from daemon: Conflict.
The container name "/tf-resource-conflict" is already in use ...

Clean up the manual container when you finish:

bash
docker rm -f tf-resource-conflict

Import and adoption workflows belong in a maintenance lesson — the takeaway here is that Terraform assumes it owns the objects it manages.

Provider not initialized

Use a directory that has never run terraform init:

bash
mkdir -p ~/terraform-labs/terraform-resource-errors/provider-not-initialized && cat > ~/terraform-labs/terraform-resource-errors/provider-not-initialized/main.tf <<'EOF'
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

resource "docker_container" "web" {
  name  = "orphan"
  image = "ghcr.io/nginx/nginx-unprivileged:alpine"
}
EOF

Run validate without initializing:

bash
cd ~/terraform-labs/terraform-resource-errors/provider-not-initialized && terraform validate

Sample output:

output
Error: Missing required provider

This configuration requires provider registry.terraform.io/kreuzwerker/docker,
but that provider isn't available. You may be able to install it automatically
by running:
  terraform init

Run terraform init before validate, plan, or apply in a new working directory that requires external providers or modules.

Symptom Likely cause Fix
Unsupported argument Typo or argument not in the resource schema Remove the key or check provider docs for the correct name
Missing required argument Required schema field omitted Add the argument the provider marks as required
Reference to undeclared resource Typo in <type>.<name> or resource in another module Fix the reference or add the missing resource block
Provider conflict / already exists Object created outside Terraform Remove the orphan, import it, or change the unique name
Missing required provider terraform init not run Run terraform init in the working directory

References


Summary

The Terraform resource block is the unit of management: resource "<TYPE>" "<NAME>" declares a resource that may produce one or many instances. Arguments describe desired state in the block body; attributes such as terraform_data.example.output are referenced elsewhere and may be unknown until apply. Addresses use type.name for a single instance and indexed forms such as type.name[0] when count or for_each is in play.

You created terraform_data.example, saw (known after apply) on the first plan, confirmed the address with terraform state list, and walked init → validate → plan → apply without cloud credentials. Adding docker_image.nginx and docker_container.web proved provider-backed management with docker ps on the host.

Changing input on terraform_data produced an in-place ~ update; renaming the container forced a -/+ replacement; removing Docker blocks from configuration proposed destroys for objects no longer in the model. Meta-arguments and provider registry docs answer different questions — how Terraform handles instances versus what each resource type allows. Reproduce errors in isolated subdirectories so each terraform validate sees only one failure. Next in the configuration track: Terraform data sources for read-only lookups.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)