Mission 0.3 · Stage 0 — First Apply
Read a Plan Like a Reviewer
Goal: Versioning and public-access protection on the assets bucket, applied from a saved plan — and the ability to spot a destructive change in any plan before you approve it.
By the end of this mission
- Recognise all plan actions:
+,~,-,-/+,+/- - Find the
# forces replacementline that turns an edit into a delete - Use references to create implicit dependencies between resources
- Save a plan with
-outand apply exactly what was reviewed
Part 1
Understand it first
The five actions a plan can show
+ create: a new object will exist. ~ update in-place: the same object, some settings change, no downtime in most cases. - destroy: the object will be deleted. -/+ destroy and then create replacement: the object will be DELETED and a new one created, because some argument can't be changed on an existing object. +/- create replacement and then destroy: the same, but the new one is created first (you'll use create_before_destroy for this in Stage 4).
For stateless things (a tag, a security-group description) replacement is harmless. For stateful things (a database, a bucket with objects, an EBS volume) replacement means data loss. That's the single most important thing to catch in a review.
Why some changes force replacement
Terraform doesn't choose this; the cloud API does. You can add tags to an existing bucket, but you can't rename a bucket, because S3 has no rename API. You can't change an RDS instance's encryption after creation, and you can't move a subnet to another AZ. The provider marks such arguments as 'ForceNew', and the plan labels the changed line # forces replacement so you can see exactly which edit caused it.
Review habit: when the summary shows any destroy, search the plan for forces replacement, check whether the resource holds data or serves traffic, and only then decide.
References build the dependency graph
When one resource uses another's attribute, such as bucket = aws_s3_bucket.assets.id, you've created an IMPLICIT DEPENDENCY. Terraform builds a graph from these references and uses it to order operations: the bucket is created before its versioning config, and on destroy the versioning config is removed first. Unrelated resources are handled in parallel.
References also carry values: if the bucket doesn't exist yet, the plan shows (known after apply) for the reference, and Terraform fills it in during apply. Only use depends_on (an explicit dependency) when there's a hidden ordering requirement that no attribute reference expresses. That's rare, and Stage 3 shows one example.
Saved plans: apply exactly what was reviewed
terraform apply with no arguments computes a NEW plan at that moment. If someone merged a change or the infrastructure drifted between your review and the apply, you could approve something you never saw. terraform plan -out=tfplan writes the reviewed plan to a file, and terraform apply tfplan executes precisely that plan with no second prompt.
If anything changed in state since the plan was saved, Terraform refuses with 'Saved plan is stale'. This is how CI pipelines work in Stage 7: plan on the pull request, review it, apply that exact artifact on merge.
Part 2
Your project after this mission · 1 file change
- infra/
- .terraform.lock.hcl
- main.tfmodified
- providers.tf
- versions.tf
- .gitignore
Part 3
Build it, step by step
- 1
Add versioning and public-access protection
In AWS provider 4 and later, bucket features are separate resources, one per concern, each pointing at the bucket by its ID. Versioning protects against overwrites and deletes (AWS course, Topic 4.1). The public access block guarantees that no policy or ACL can make the bucket public, even by mistake.
Both use
aws_s3_bucket.assets.id, which is the reference that creates the dependency.infra/main.tfadd to filehcl resource "aws_s3_bucket_versioning" "assets" { bucket = aws_s3_bucket.assets.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "assets" { bucket = aws_s3_bucket.assets.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } - 2
Save the plan instead of applying straight away
Write the plan to a file. Because the bucket already exists, the reference resolves to a real value in the plan (
bucket = "shoplite-assets-ak-7x2q") rather than(known after apply).terminal$ terraform plan -out=tfplan── expected output ──aws_s3_bucket.assets: Refreshing state... [id=shoplite-assets-ak-7x2q]Terraform will perform the following actions:# aws_s3_bucket_public_access_block.assets will be created+ resource "aws_s3_bucket_public_access_block" "assets" {+ block_public_acls = true+ block_public_policy = true+ bucket = "shoplite-assets-ak-7x2q"+ id = (known after apply)+ ignore_public_acls = true+ restrict_public_buckets = true}# aws_s3_bucket_versioning.assets will be created+ resource "aws_s3_bucket_versioning" "assets" {+ bucket = "shoplite-assets-ak-7x2q"+ id = (known after apply)+ versioning_configuration {+ mfa_delete = (known after apply)+ status = "Enabled"}}Plan: 2 to add, 0 to change, 0 to destroy.Saved the plan to: tfplanTo perform exactly these actions, run the following command to apply:terraform apply "tfplan" - 3
Review the saved plan, then apply exactly that
terraform show tfplanre-renders a saved plan, which is how a reviewer inspects it later.-jsongives machine-readable output that policy tools consume in Stage 7. Applying a saved plan doesn't prompt, because the review already happened.terminal$ terraform show tfplan | tail -n 3terraform apply tfplan── expected output ──Plan: 2 to add, 0 to change, 0 to destroy.aws_s3_bucket_versioning.assets: Creating...aws_s3_bucket_public_access_block.assets: Creating...aws_s3_bucket_public_access_block.assets: Creation complete after 1s [id=shoplite-assets-ak-7x2q]aws_s3_bucket_versioning.assets: Creation complete after 2s [id=shoplite-assets-ak-7x2q]Apply complete! Resources: 2 added, 0 changed, 0 destroyed.Both were created at the same time: they depend on the bucket, but not on each other. - 4
Provoke a replacement — and do NOT apply it
Change the bucket name by one character (for example
-7x2qto-7x2r) and plan. The bucket shows-/+, and thebucketline carries# forces replacement. Both dependent resources are replaced too, because theirbucketargument is ForceNew as well.Read the summary:
3 to add, 0 to change, 3 to destroy. A one-character edit is about to delete a bucket along with every object in it. This is the review skill to learn in this mission.terminal$ terraform plan── expected output ──# aws_s3_bucket.assets must be replaced-/+ resource "aws_s3_bucket" "assets" {~ arn = "arn:aws:s3:::shoplite-assets-ak-7x2q" -> (known after apply)~ bucket = "shoplite-assets-ak-7x2q" -> "shoplite-assets-ak-7x2r" # forces replacement~ id = "shoplite-assets-ak-7x2q" -> (known after apply)# (8 unchanged attributes hidden)}# aws_s3_bucket_public_access_block.assets must be replaced-/+ resource "aws_s3_bucket_public_access_block" "assets" {~ bucket = "shoplite-assets-ak-7x2q" -> (known after apply) # forces replacement# (4 unchanged attributes hidden)}# aws_s3_bucket_versioning.assets must be replaced-/+ resource "aws_s3_bucket_versioning" "assets" {~ bucket = "shoplite-assets-ak-7x2q" -> (known after apply) # forces replacement# (1 unchanged block hidden)}Plan: 3 to add, 0 to change, 3 to destroy. - 5
Revert, and preview a destroy without doing one
Put the original name back and confirm the plan is clean. Then look at
plan -destroy, which shows whatterraform destroyWOULD remove. It's useful before tearing down an environment, and it changes nothing.terminal$ terraform plan -destroy | tail -n 1── expected output ──Plan: 0 to add, 0 to change, 3 to destroy. - 6
Delete the used plan file and commit
A saved plan is single-use; once applied, it's stale. It's already git-ignored (the
tfplanline from Mission 0.1's.gitignore), because saved plans can contain sensitive values. Delete it and commit the code.terminal$ rm tfplangit add -A && git commit -m "assets bucket: versioning + public access block"── expected output ──[main 4b1c2d9] assets bucket: versioning + public access block1 file changed, 16 insertions(+)
Checkpoint — you should now have
- ✓The bucket has versioning enabled and all four public-access-block settings on.
- ✓You applied from a saved plan file, not an interactive
apply. - ✓You saw a
-/+replacement with# forces replacementand did NOT apply it. - ✓
terraform planis clean again, andtfplanis git-ignored.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Replace a bucket that has data in it
Upload a file, then change the bucket name and apply the replacement:
echo hello > logo.txt && aws s3 cp logo.txt s3://shoplite-assets-ak-7x2q/ — then edit the name and run terraform apply, answering yes.
Break #2
Apply a stale saved plan
Run terraform plan -out=tfplan. Then change the Owner tag and run a normal terraform apply. Finally run terraform apply tfplan.
Break #3
Reference a resource that doesn't exist
In aws_s3_bucket_versioning.assets, change the reference to aws_s3_bucket.asset.id (drop the s), then run terraform validate.
Part 5
Interview questions from this mission
You're reviewing a teammate's plan. What do you check first, and what makes you stop?
Why does changing a bucket's name destroy the bucket, when changing its tags doesn't?
What's the difference between terraform apply and terraform apply tfplan?
How does Terraform decide the order in which to create resources? When would you use depends_on?