| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1kreuzwerker/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.
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.
~/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 resource —
terraform_datais 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:
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:
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:
terraform_data.example.outputFor 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:
<resource_type>.<local_name> # single instance
terraform_data.example[0] # count index
terraform_data.example["web"] # for_each keyFor 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:
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:
mkdir -p ~/terraform-labs/terraform-resourceMove into it — every command below assumes you are here:
cd ~/terraform-labs/terraform-resourceWrite the root module:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "example" {
input = "Hello Terraform"
}
output "message" {
value = terraform_data.example.output
}
EOFInspect plan, apply, and state
Initialize the working directory so Terraform can evaluate the configuration:
terraform initSample output (trimmed):
Terraform has been successfully initialized!Check that the configuration is syntactically valid:
terraform validateSample output:
Success! The configuration is valid.Preview what Terraform will create:
terraform planSample output (trimmed):
# 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:
terraform apply -auto-approveSample output (trimmed):
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:
terraform state listSample output:
terraform_data.exampleThat 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:
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
}
EOFDownload the Docker provider plugin:
terraform initApply the expanded configuration:
terraform apply -auto-approveSample output (trimmed):
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:
docker ps --filter name=tf-resource-webSample 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-webThree 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:
sed -i 's/Hello Terraform/Updated input/' main.tfPreview the change:
terraform planSample output (trimmed):
# 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:
terraform apply -auto-approveSample output (trimmed):
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:
sed -i 's/tf-resource-web/tf-resource-web-v2/' main.tfReview the replacement plan:
terraform planSample output (trimmed):
# 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:
terraform apply -auto-approveSample output (trimmed):
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:
docker ps --filter name=tf-resource-web-v2The 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:
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
}
EOFPreview deletions Terraform infers from the missing blocks:
terraform planSample output (trimmed):
# 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:
terraform apply -auto-approveSample output (trimmed):
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:
docker ps -a --filter name=tf-resource-web-v2No 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:
- Terraform core behavior — plan symbols (
+,~,-,-/+), addresses, and references. - Provider resource schema — the
docker_containeroraws_instancepage 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:
resource "<TYPE>" "<NAME>" { ... } → manage an object (create, update, delete)
data "<TYPE>" "<NAME>" { ... } → read existing information into the moduleUse 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:
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:
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
}
EOFInitialize and validate in that directory only:
cd ~/terraform-labs/terraform-resource-errors/unsupported-argument && terraform init -input=false && terraform validateSample 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:
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"
}
EOFInitialize and validate:
cd ~/terraform-labs/terraform-resource-errors/missing-argument && terraform init -input=false && terraform validateSample 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:
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
}
EOFValidate after init:
cd ~/terraform-labs/terraform-resource-errors/undeclared-reference && terraform init -input=false && terraform validateSample 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:
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"
}
EOFInitialize the directory:
cd ~/terraform-labs/terraform-resource-errors/name-conflict && terraform init -input=falseCreate the same container name outside Terraform so the provider hits a name collision:
docker run -d --name tf-resource-conflict ghcr.io/nginx/nginx-unprivileged:alpineApply and read the provider conflict:
cd ~/terraform-labs/terraform-resource-errors/name-conflict && terraform apply -auto-approveSample output (trimmed):
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:
docker rm -f tf-resource-conflictImport 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:
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"
}
EOFRun validate without initializing:
cd ~/terraform-labs/terraform-resource-errors/provider-not-initialized && terraform validateSample 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 initRun 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
- Terraform language: Resources
- Resource behavior
- terraform_data resource
- Meta-arguments
- Docker provider: docker_container
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.

