Quick answer: Managing infrastructure resources in Terraform follows one repeatable loop: define the resource in HCL, run terraform plan to preview the change, run terraform apply to make it real, let the state file track what exists, and run terraform destroy when the resource is no longer needed. Every update β adding a tag, resizing a server, deleting a bucket β goes through the same loop.
This guide walks through the full life of a resource: creating it, changing it in place, forcing a replacement, importing something that already exists, and removing it safely. You will also learn how to read plan symbols, which commands modify state, and the mistakes that cause unplanned downtime.
Terraform does not have separate “create” and “update” commands. There is a single reconciliation loop: compare what the code says should exist with what the state file says does exist, compute the difference, execute it. Whether that difference is three new resources or one changed attribute, the commands are identical. Understanding this removes most of the mystery from Terraform.
Step 1: Define the resource
A resource block names a type, a local label and the arguments the provider requires. Here is a security group and an EC2 instance that uses it:
resource "aws_security_group" "web" {
name = "tkh-web-sg"
description = "Allow HTTP from anywhere"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web" {
ami = "ami-0f58b397bc5c1f2e8" # Ubuntu 24.04, ap-south-1
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "tkh-web"
}
}
The reference aws_security_group.web.id does two jobs: it passes the group’s ID into the instance and it tells Terraform the security group must exist first. You rarely need to spell out ordering by hand.
Step 2: Plan, read the output, then apply
terraform plan is your safety net. It calls the provider to refresh the current state of each managed object, compares it with your code, and prints a diff. Learn the symbols:
| Symbol | Meaning | Downtime risk |
|---|---|---|
+ |
Create a new resource | None |
~ |
Update in place | Usually none |
-/+ |
Destroy and recreate (forced replacement) | Yes β resource disappears briefly |
- |
Destroy | Yes β permanent |
<= |
Read a data source | None |
The plan ends with a summary such as Plan: 2 to add, 0 to change, 0 to destroy. Save it with terraform plan -out=tfplan when you want to guarantee that the apply executes exactly what was reviewed, which is how CI pipelines work.
terraform apply (or terraform apply tfplan) then executes the plan. Independent resources are created in parallel, up to 10 at a time, while dependent ones wait. When it finishes, Terraform writes the new state and prints any outputs. If something fails halfway, the resources that did succeed are recorded in state, so re-running apply continues from where it stopped rather than starting over.
Step 3: Update β in place or by replacement
Change instance_type from t3.micro to t3.small and the plan shows ~: AWS can resize a stopped instance, so Terraform modifies it in place. Change the ami and the plan shows -/+, because an instance’s image cannot be swapped β the only option is a new instance. Provider documentation marks such attributes as “forces replacement”.
For resources that serve traffic, use a lifecycle block so the new copy is created before the old one is destroyed:
resource "aws_instance" "web" {
# ... arguments as before ...
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastPatched"]]
}
}
ignore_changes tells Terraform to tolerate attributes that something else modifies β here, a patching script that updates a tag β so every plan is not cluttered with noise.
Step 4: Understand what state is doing
The state file is the ledger linking aws_instance.web in your code to i-0abc123 in AWS. Useful commands:
terraform state listβ show every managed resource address.terraform state show aws_instance.webβ print all known attributes of one resource.terraform state mvβ rename a resource address without destroying it (prefer amovedblock in Terraform 1.1+).terraform state rmβ forget a resource without deleting it in the cloud (prefer aremovedblock in 1.7+).
In a team, store state in a remote backend (S3 with state locking, Azure Blob, GCS, or HCP Terraform) so that two engineers cannot apply at the same moment. For a refresher on how state fits with the other building blocks, see Terraform Core Concepts and Syntax: A Beginner’s Guide.
Step 5: Import existing resources, and destroy safely
Most companies already have infrastructure that was clicked together in the console. Terraform 1.5+ lets you adopt it declaratively with an import block:
import {
to = aws_s3_bucket.legacy_logs
id = "company-logs-2019"
}
resource "aws_s3_bucket" "legacy_logs" {
bucket = "company-logs-2019"
}
Run terraform plan -generate-config-out=generated.tf and Terraform even writes the resource block for you based on what it finds. After apply, the bucket is tracked in state and future changes go through code.
At the other end of the lifecycle, terraform destroy removes every resource in the state, in reverse dependency order β instances before security groups, subnets before VPCs. To remove only one resource, delete its block from the code and run a normal apply; the plan will show a single -. Protect anything irreplaceable with prevent_destroy = true inside its lifecycle block; Terraform will refuse to plan the deletion until you remove that line deliberately.
The ordering engine behind all of this β implicit references, depends_on and parallelism β is covered in Understanding Resource Dependencies and Ordering in Terraform.
Mistakes that cause outages
- Not noticing
-/+in the plan. Replacing an RDS instance or a stateful disk means data loss unless you have snapshots. Read every plan for the word “replace”. - Renaming a resource label. Changing
"web"to"frontend"looks cosmetic but Terraform sees delete-plus-create. Use amovedblock instead. - Manual console changes. Terraform will revert them on the next apply. If the change must stay, put it in code or add it to
ignore_changes. - Overusing
-target. Applying one resource at a time leaves state partially updated and hides dependency problems. Reserve it for recovery. - Running
destroyin the wrong workspace. Always checkterraform workspace showand the backend configuration before destroying anything.
Frequently asked questions
Does terraform apply always require typing “yes”?
Interactively, yes. In CI you pass -auto-approve or apply a saved plan file. Never use -auto-approve on a fresh plan against production.
What happens if the apply fails halfway?
Terraform records whatever was created, marks resources that could not be fully configured as “tainted”, and exits non-zero. Fix the cause and run apply again; it resumes from the current state.
How do I update a resource without downtime?
Prefer attributes that update in place, enable create_before_destroy for replacements, and put stateful data (databases, volumes) in resources that are separate from the compute that uses them.
Can Terraform manage resources created by someone else?
Yes, with import blocks or the terraform import command. Once imported, Terraform owns the resource and will reconcile it like any other.
Key takeaways
- Create, update and delete all flow through the same
planthenapplyloop. - Plan symbols tell you the downtime risk;
-/+deserves a second look every time. - State is the source of truth linking code to real objects β keep it remote and locked.
lifecycle,moved,importandremovedblocks let you change resources without surprises.
Want hands-on practice creating, updating and destroying real AWS and Azure resources with Terraform? Our DevOps course covers Terraform end to end with live projects, mentor support and placement assistance. Prefer video? Follow along on our YouTube channel.


