Reported symptoms
The change was three resources: a parameter group, a reporting database that uses it, and a security group rule that lets the reporting service reach it. It ran on the nightly apply job, which has a 45-minute timeout that has never been reached before.
The job log ends like this, with no summary line:
aws_db_parameter_group.reporting: Creation complete after 3s [id=reporting-pg16]
aws_db_instance.reporting: Creating...
aws_db_instance.reporting: Still creating... [10m0s elapsed]
aws_db_instance.reporting: Still creating... [20m0s elapsed]
aws_db_instance.reporting: Still creating... [30m0s elapsed]
aws_db_instance.reporting: Still creating... [40m0s elapsed]
The first person to look at it in the morning cannot get a plan at all:
Error: Error acquiring the state lock
Lock Info:
ID: 7c2e1c91-4cc0-2b1d-a64b-4db28d71b82d
Operation: OperationTypeApply
Who: ci-job-4471
Created: 2026-08-18 02:47:11 +0000 UTC
ci-job-4471 finished at 02:47. Nothing is holding the lock except a record of
something that no longer exists. Once that is cleared, the plan reads:
Plan: 2 to add, 0 to change, 0 to destroy.
Which looks entirely reasonable - the apply did not get that far - until
somebody opens the RDS console and finds reporting-prod, status available,
created at 02:57.
Two more things confuse the picture. A refresh-only plan reports no drift at all, which reads as a clean bill of health for the state file. And the first recovery attempt, re-running the apply, fails with an identifier collision: a completely different error, on a resource the previous run also failed on, which is easy to read as a second problem rather than as the first one talking back.
Evidence provided
What state knows about:
# READ-ONLY
terraform state list
aws_db_parameter_group.reporting
One entry. The database is not there, and neither is the security group rule.
What the account actually contains:
# READ-ONLY: ask the provider directly, not through Terraform
aws rds describe-db-instances \
--db-instance-identifier reporting-prod \
--query 'DBInstances[0].{Status:DBInstanceStatus,Class:DBInstanceClass,Created:InstanceCreateTime}'
{
"Status": "available",
"Class": "db.r6g.2xlarge",
"Created": "2026-08-18T02:57:44Z"
}
Created ten minutes after the job that asked for it was killed.
What refresh has to say:
# READ-ONLY
terraform plan -refresh-only -no-color
No changes. Your infrastructure matches the configuration.
And the backend’s own record of the last write:
# READ-ONLY: the state versions the backend is holding
aws s3api list-object-versions \
--bucket acme-tf-state-eu-west-2 \
--prefix reporting/terraform.tfstate \
--query 'Versions[0:3].[LastModified,VersionId]'
The most recent version is timestamped at the parameter group’s creation. Nothing was written after it.
Work the evidence before reading on
The refresh-only plan is the piece to sit with. It is not lying and it is not broken.
- Refresh asks the provider to read each resource. Which resources does it ask about, and where does that list come from?
- Given that list, could a refresh-only plan ever have reported the database? What does that tell you about the class of problem Terraform can detect on its own?
- The plan proposes
2 to add. Both of those adds are correct given what Terraform knows. Which one is safe and which one is not, and what makes the difference? - The re-apply failed with an identifier collision. Was that a bad outcome?
Before continuing: state can be wrong in two directions. Name both, and work out which of them Terraform will tell you about without being asked.
Root cause
1. The write happens after the object exists, not before
Terraform records a resource in state when the provider returns a successful create together with the object’s identifier. That identifier is the only handle Terraform will ever have on the object, and it comes back exactly once.
Creating a managed database is asynchronous. The provider issues one call, the
service accepts it and starts building, and the provider then polls until the
object reports ready. Every Still creating... line in that log is a poll. The
object was already being built by the time the second one printed.
The runner killed the process at 45 minutes. That cancelled the poll. It did not cancel anything on the service’s side, because there was nothing left to cancel - the request had been accepted twenty minutes earlier. So the object finished building at 02:57, into an account where nothing was recording that it had been asked for.
2. State is behind reality, and that direction is invisible
This is the part worth internalising, because it changes what the first action is.
State can be wrong in two directions, and they are not symmetrical:
- State ahead of reality. State holds an object the cloud no longer has - somebody deleted it in the console, or a create was rolled back. Refresh detects this: it asks the provider to read the object, the provider reports it is gone, and the next plan proposes to recreate it. Terraform finds this class of problem for you.
- State behind reality. The cloud holds an object state has no entry for. Refresh cannot detect this, and no amount of running it will help. Refresh iterates over the resources in state; an object in no state entry is never asked about. Terraform has no discovery. It does not enumerate the account and it never intended to.
The refresh-only plan was empty because there was, correctly, nothing to report. Reading that as “state is fine” is the error, and it is an easy one: the command answered a narrower question than the one being asked of it.
3. The collision was the good outcome
The re-apply failed because reporting-prod is a fixed identifier and the
service will not accept a second object under it. That failure cost the team
twenty minutes and saved them a duplicate production database.
Nothing about the incident guaranteed that outcome. It is a property of the resource: an identifier the configuration fixes collides; an identifier the provider or the service generates does not. Change one line of the configuration and the same re-apply succeeds, and the team owns two databases, one of which nothing points at and nothing manages.
Resolution
-
Clear the lock deliberately. Confirm the named job is finished - not assumed finished - then
force-unlockwith the exact lock id from the error, and record who did it and why.-lock=falseis not a substitute; it does not clear a stale lock, it lets a second writer past a live one. -
Do not apply. Say so explicitly, because the plan looks reasonable and the change is already approved.
-
Reconcile the account against state. Take the resource list from the interrupted plan, and for each one ask the provider directly whether the object exists - by name, by tag, and by creation window around the time the job died. Terraform will not do this for you. The output is a table with one row per resource in the change and a definite answer in each row.
-
Decide adopt or destroy, and write the reason down. Adopt when the object is complete, healthy, and the one the configuration describes. Destroy out of band and let Terraform build it cleanly when it is half-built, when the configuration has moved on since the run, or when rebuilding is cheap and the estate is better for having no imported history. Both are correct answers; an undecided one is not.
-
Adopt through the configuration, not the shell. The import block is reviewable and the CLI form is not, and this is an adoption of a production database into a production state file:
import { to = aws_db_instance.reporting id = "reporting-prod" } -
Plan, and read every attribute the import surfaces. An import writes what the object really is. A diff after it is a genuine disagreement between the object and the configuration - the storage that was autoscaled, the parameter group that was attached, the maintenance window the service chose
- and each one is a decision, not noise to be applied away.
-
Remove the import block once it has run, in the same way a spent
movedblock is removed. It is a one-shot declaration and leaving it in place is noise at best. -
Finish the change. The security group rule never started. A normal apply creates it, and only now is that apply safe, because the account and state finally agree about what already exists.
Verification
- There is exactly one of it. Query the provider by name and by tag and count the results. This is the check that can fail and no plan can perform it: Terraform sees only the object it has adopted, so a duplicate is invisible to every command in the toolchain.
- The adopted object is the one that was running. Compare the identifier in state against the identifier recorded before the import. A vanished diff is consistent with a correct adoption and also with having imported the wrong object.
- The plan is empty with refresh enabled. No creates, no replacements. The refresh-only plan was already empty before any of this started and remains worthless as evidence.
- The lock is released and the serial advanced as expected - once for the import, once for the apply that finished the change. A serial that moved more often than that means something else wrote state.
- The inventory and the cost report agree. Their disagreement was the only signal that anything was missing, so it is also the signal that the account is whole again.
- The rest of the change exists. The security group rule, and anything else the interrupted run had not reached. Closing the incident on the object that caused it leaves the change half-applied, which is a smaller version of the same problem.
Prevention
- Treat the invariant as the design constraint. Killing an apply does not cancel the cloud operations it started. Every practice below is a consequence of that one sentence.
- Set the job timeout above the longest create in the configuration, and treat a timeout as an incident rather than something to retry. The run that gets killed is the run that leaves an orphan.
- Interrupt once, not twice. A single interrupt lets Terraform finish the operation in flight and write its result. The second one, and any hard kill from a runner, is what converts a cancelled change into an untracked object.
- Apply a saved plan. The set of resources a run can touch is then known and reviewable before it starts, which makes the reconciliation after an interrupted run a bounded exercise instead of a search.
- Tag every object with the workspace and state key that owns it. An orphan is then discoverable by query. Without it, discovery depends on somebody remembering what the run was doing.
- Reconcile the account against state on a schedule. Terraform cannot raise
this class of problem, so something else has to. Comparing an inventory query
against
terraform state listis a small job that finds the incidents nobody reported. - Prefer fixed identifiers where a duplicate would be expensive, so that a re-apply after an interrupted run fails immediately instead of succeeding quietly.