Your first resource
Providers, plan, apply, destroy — and reading a plan.
Part 1: The problem
Theo was new to Terraform. QuickCart’s image repository was called quickcart-orders, and he wanted it to match a new naming scheme: quickcart-orders-api. One word, in one file.
He ran terraform apply. A long plan scrolled past, ending in Plan: 1 to add, 0 to change, 1 to destroy. That looked like a rename, so he typed yes.
It wasn’t a rename. AWS can’t rename an image repository, so Terraform deleted the old one — with every image the team had ever pushed — and made a new, empty one. The next deployment couldn’t find the image it needed. The plan had said so, in one short comment: # forces replacement.
Terraform told Theo exactly what it would do. He just couldn’t read it yet.
This lesson builds one real resource and teaches you to read what Terraform says it will do with it.
Part 2: The idea
A Terraform project is a folder of .tf files. Terraform reads all of them together, so how you split them into files is up to you. A few names are usual:
| File | What usually goes in it |
|---|---|
main.tf | The resources |
variables.tf | Inputs. Covered in Variables, locals and outputs |
outputs.tf | Values to print or share |
backend.tf | Where state lives. Covered in Terraform state |
Providers: how Terraform talks to AWS
Terraform itself knows nothing about AWS. A provider is a plugin that teaches it one platform: every resource type, and how to create, change and delete each one. There are providers for AWS, Azure, Google Cloud, GitHub and hundreds more.
You say which provider you need, and which versions are allowed, in a terraform block:
| Version constraint | What it allows | When you’d use it |
|---|---|---|
"~> 6.0" | Any 6.x, never 7.0 | The usual choice: fixes and new features, no breaking changes |
"6.66.0" | Exactly that version | Rarely. You’d miss every fix |
">= 6.0" | 6.0 or anything newer, including 7 | Avoid: a new major version can break your code without warning |
A major version is the first number. When it goes up, things you rely on may change or disappear. That’s why ~> stops before it.
Resources: the things you build
A resource is one thing in AWS that Terraform creates and looks after. Its first line has two names:
| Part | Example | What it is |
|---|---|---|
resource | Create and manage something | |
| Type | aws_ecr_repository | What kind of thing. The provider defines the types |
| Name | orders | Your code’s name for it. Other code refers to it as aws_ecr_repository.orders |
name = inside | "quickcart-orders" | What AWS calls it |
orders and quickcart-orders are different names for different readers. One is for your code; the other is what you see in the console.
Four commands
| Command | What it does | How often |
|---|---|---|
terraform init | Download the providers and set up the folder | Once, and again when you add a provider or module |
terraform plan | Show what would change. Changes nothing | Before every apply |
terraform apply | Show the plan, ask for yes, then make the changes | Whenever you change the code |
terraform destroy | Delete everything this folder manages | Practice environments only |
Reading a plan
Each resource in a plan starts with a symbol. It tells you how careful to be.
| Symbol | Meaning | How careful to be |
|---|---|---|
+ | Create | Normal |
~ | Change in place. The resource stays; some settings change | Read which settings |
-/+ | Destroy, then create a replacement | Stop and read. For a database, this is data loss |
+/- | Create the replacement first, then destroy the old one | Read carefully. Only happens with a lifecycle setting, covered in Loops and lifecycle |
- | Destroy | Stop and read |
<= | Read something that already exists | Harmless |
Inside a resource, each line has a symbol too, and a few phrases matter:
| You see | What it means |
|---|---|
"old" -> "new" | This setting changes from old to new |
# forces replacement | This line is why the resource must be destroyed and rebuilt. AWS can’t change it in place |
(known after apply) | AWS decides this value when it creates the resource, so Terraform can’t show it yet |
-> null | This value will be gone |
# (6 unchanged attributes hidden) | Terraform hides settings that don’t change, so the real change stands out |
Which settings force replacement is decided by AWS, not by you. You can’t memorise them all, and you don’t need to. The plan always tells you.
What init leaves in the folder
| Item | What it is | Commit to git? |
|---|---|---|
.terraform/ | The downloaded provider plugin. Hundreds of megabytes | No. init downloads it again anywhere |
.terraform.lock.hcl | The exact provider version chosen, with checksums of the download | Yes |
terraform.tfstate | Appears after apply: Terraform’s record of what it built | No. It can hold secrets. It moves to S3 in Terraform state |
Part 3: See it
Here is QuickCart’s repository in code. Pick a change, and see the plan Terraform makes for it.
- +create
- ~update in place
- -/+destroy, then create
- -destroy
main.tf resource "aws_ecr_repository" "orders" { name = "quickcart-orders" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" Team = "platform" } }
$ terraform plan # aws_ecr_repository.orders will be updated in-place ~ resource "aws_ecr_repository" "orders" { id = "quickcart-orders" name = "quickcart-orders" ~ tags = { "Project" = "quickcart" + "Team" = "platform" } # (6 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy.
Real Terraform output, trimmed to the lines that matter.
What to notice: adding a tag and renaming the repository are both one-line changes. One is a harmless ~. The other is a -/+ that deletes every image inside. The size of the change in your code tells you nothing; only the plan does.
Part 4: Do it
Free — one empty ECR repository, which costs nothing, created and destroyed in the same lab.
You’ll build QuickCart’s image repository with Terraform, change it in place, see a change that would force replacement, and destroy it. About 30 minutes.
You need Terraform (see Why Terraform) and a working AWS login in your terminal (Set up your computer covers it). Terraform uses the same login as the AWS CLI. Check it:
aws sts get-caller-identity| Part | What it means |
|---|---|
aws sts get-caller-identity | ask AWS “who am I?” — it answers with your account and user |
{
"UserId": "AIDAEXAMPLEUSERID1234",
"Account": "111122223333",
"Arn": "arn:aws:iam::111122223333:user/quickcart-admin"
}Your account number and user name will be your own. If this command fails, fix your login first: Terraform will fail the same way.
Make the project folder
Terminalmkdir quickcart-tfPart What it means mkdirmake a directory quickcart-tfits name. Later lessons build on this folder Terminalcd quickcart-tfPart What it means cdchange directory. Every later command runs in here quickcart-tfthe folder you just made Write the code
Create
main.tfin that folder:main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } resource "aws_ecr_repository" "orders" { name = "quickcart-orders" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" } }Setting What it does If you leave it out When you’d change it terraformblockSettings for Terraform itself, not for AWS Terraform runs with no version rules at all — required_versionRefuse to run on a Terraform older than 1.10 An older Terraform may fail later, with a confusing error When you start using a newer Terraform feature required_providers→awsWhich provider this code needs, under the local name awsTerraform guesses hashicorp/awsand takes the newest version, even a new major oneWhen you add another provider sourceWhere to download it: hashicorp/awson the public Terraform RegistryTerraform assumes hashicorp/aws. Saying it is clearerAlmost never versionWhich versions are allowed: any 6.x Always the newest, which one day brings breaking changes When you deliberately move to a new major version provider "aws"Settings for the AWS provider Terraform still uses the provider, with default settings — regionWhere the resources are built Terraform uses the region from your AWS login settings, which may not be what you expect To build somewhere else resource "aws_ecr_repository" "orders"One ECR repository. ordersis your code’s name for it— — nameThe repository’s name in AWS Required: plan stops with an error Never casually. Changing it replaces the repository, as you’ll see image_tag_mutabilityIMMUTABLE: oncev1is pushed, no other image can take that tagDefaults to MUTABLE: someone can push a different image under an old tagRarely. Storing images: ECR explains why immutable is safer force_deleteLet terraform destroydelete the repository even if it holds imagesDestroy fails while any image is inside Set it to falsefor production, so images can’t be deleted by accidenttagsLabels to find and cost everything that belongs to QuickCart No labels. Harder to find later Add owner and environment tags in real projects Now tidy the formatting:
Terminalterraform fmtPart What it means terraform fmtline up the =signs and fix the spacing in every.tffile hereIt prints the name of any file it changed, or nothing if the file was already tidy. Everyone running
fmtmeans code reviews show real changes, not spacing.Initialise the folder
Terminalterraform initPart What it means terraform initdownload the AWS provider and prepare this folder You should seeInitializing the backend... Initializing provider plugins... - Finding hashicorp/aws versions matching "~> 6.0"... - Installing hashicorp/aws v6.66.0... - Installed hashicorp/aws v6.66.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!Your version number will probably be newer. Now check the code makes sense:
Terminalterraform validatePart What it means terraform validatecheck the code for mistakes — wrong types, unknown settings — without contacting AWS You should seeSuccess! The configuration is valid.Open
.terraform.lock.hclin your editor. It starts like this:The start of .terraform.lock.hcl# This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "6.66.0" constraints = "~> 6.0" hashes = [versionis what was chosen;constraintsis the range you allowed. The hashes are checksums, so a tampered download is refused. When a colleague runsinit, they get exactly this version, not whatever is newest that day.Keep the right files out of git
If this folder will go into git, create a file called
.gitignorenext tomain.tf:.gitignore.terraform/ *.tfstate *.tfstate.*Line What it keeps out of git .terraform/the downloaded plugin: large, and initfetches it again*.tfstatethe state file, which can hold secrets *.tfstate.*the backup copy Terraform keeps, terraform.tfstate.backup.terraform.lock.hclisn’t listed, so it is committed. That’s what you want.Read the plan
Terminalterraform planPart What it means terraform plancompare the code, the state and AWS, and show what would change. Changes nothing You should seeTerraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # aws_ecr_repository.orders will be created + resource "aws_ecr_repository" "orders" { + arn = (known after apply) + force_delete = true + id = (known after apply) + image_tag_mutability = "IMMUTABLE" + name = "quickcart-orders" + region = "us-east-1" + registry_id = (known after apply) + repository_url = (known after apply) + tags = { + "Project" = "quickcart" } + tags_all = { + "Project" = "quickcart" } } Plan: 1 to add, 0 to change, 0 to destroy.Every line starts with
+: all new.arn,idandrepository_urlare(known after apply), because AWS makes them up when it creates the repository.tags_allis your tags plus any the provider adds for every resource; here they’re the same.Apply it
Terminalterraform applyPart What it means terraform applymake the plan again, show it, and carry it out once you type yesIt shows the same plan. Read the summary line, then type
yes:You should seePlan: 1 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes aws_ecr_repository.orders: Creating... aws_ecr_repository.orders: Creation complete after 1s [id=quickcart-orders] Apply complete! Resources: 1 added, 0 changed, 0 destroyed.Check it in the console:
In the AWS console: Amazon ECRPrivate registryRepositories
You should seequickcart-ordersin the list, with Tag immutability shown as immutable. Built from one short file, and you can build it again identically whenever you like.Change it in place
Add a second tag,
Team. The whole file now reads:main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } resource "aws_ecr_repository" "orders" { name = "quickcart-orders" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" Team = "platform" } }Setting What changed tags→Teama second label, saying which team owns the repository. Every other setting is as before Terminalterraform applyPart What it means terraform applyplan the tag change, show it, and carry it out once you type yesYou should seeTerraform will perform the following actions: # aws_ecr_repository.orders will be updated in-place ~ resource "aws_ecr_repository" "orders" { id = "quickcart-orders" name = "quickcart-orders" ~ tags = { "Project" = "quickcart" + "Team" = "platform" } ~ tags_all = { + "Team" = "platform" # (1 unchanged element hidden) } # (6 unchanged attributes hidden) # (2 unchanged blocks hidden) } Plan: 0 to add, 1 to change, 0 to destroy.~: the same repository, updated. Typeyes:You should seeaws_ecr_repository.orders: Modifying... [id=quickcart-orders] aws_ecr_repository.orders: Modifications complete after 1s [id=quickcart-orders] Apply complete! Resources: 0 added, 1 changed, 0 destroyed.See a change that forces replacement — and don't apply it
Now do what Theo did. Change the name to
quickcart-orders-api:main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } resource "aws_ecr_repository" "orders" { name = "quickcart-orders-api" image_tag_mutability = "IMMUTABLE" force_delete = true tags = { Project = "quickcart" Team = "platform" } }Setting What changed namequickcart-ordersbecomesquickcart-orders-apiThis time, only plan:
Terminalterraform planPart What it means terraform planshow what the rename would do, without doing it You should seeTerraform will perform the following actions: # aws_ecr_repository.orders must be replaced -/+ resource "aws_ecr_repository" "orders" { ~ arn = "arn:aws:ecr:us-east-1:111122223333:repository/quickcart-orders" -> (known after apply) ~ id = "quickcart-orders" -> (known after apply) ~ name = "quickcart-orders" -> "quickcart-orders-api" # forces replacement ~ registry_id = "111122223333" -> (known after apply) ~ repository_url = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-orders" -> (known after apply) tags = { "Project" = "quickcart" "Team" = "platform" } # (4 unchanged attributes hidden) } Plan: 1 to add, 0 to change, 1 to destroy.There are a few more lines about the repository’s settings blocks; they don’t matter here. Read the rest:
Line What it tells you must be replacedand-/+the repository will be destroyed, then a new one created # forces replacementthe namechange is the causerepository_url ... -> (known after apply)the address changes, so anything pushing or pulling images breaks until it’s updated 1 to add, 1 to destroynot a rename. A delete and a create Don’t apply it. Change the name back to
quickcart-orders, then plan again:Terminalterraform planPart What it means terraform plancheck the code matches what’s built again You should seeNo changes. Your infrastructure matches the configuration.Clean-up step: Clean up
Terminalterraform destroyPart What it means terraform destroyplan the removal of everything this folder built, and carry it out once you type yesIt shows the plan, with every line starting
-. Check the summary says1 to destroy, then typeyes:You should seePlan: 0 to add, 0 to change, 1 to destroy. Do you really want to destroy all resources? Terraform will destroy all your managed infrastructure, as shown above. There is no undo. Only 'yes' will be accepted to confirm. Enter a value: yes aws_ecr_repository.orders: Destroying... [id=quickcart-orders] aws_ecr_repository.orders: Destruction complete after 1s Destroy complete! Resources: 1 destroyed.Check Amazon ECR → Private registry → Repositories:
quickcart-ordershas gone.Keep the
quickcart-tffolder. Terraform state starts from it.
Part 5: Check yourself
A plan line starts with -/+ and ends with "# forces replacement". What will happen?
Which file should you commit to git?
What does version = "~> 6.0" allow?
You haven't changed anything, and plan says "Plan 0 to add, 1 to change, 0 to destroy". What's the most likely cause?
What is a provider?
Part 6: Cheat sheet
| Command or symbol | What it means |
|---|---|
terraform init | Download providers, create .terraform.lock.hcl. Again after adding a provider or module |
terraform fmt | Tidy the formatting |
terraform validate | Check the code without contacting AWS |
terraform plan | Show what would change. Changes nothing |
terraform apply | Show the plan, ask for yes, make the changes |
terraform destroy | Delete everything this folder manages |
+ / ~ / - | Create / change in place / destroy |
-/+ and # forces replacement | Destroy then create. Stop and read |
(known after apply) | AWS decides the value when it creates the resource |
version = "~> 6.0" | Any 6.x provider, never 7.0 |
| Commit | .tf files and .terraform.lock.hcl |
| Never commit | .terraform/ and *.tfstate |