If you run terraform plan in CI and it comes back clean, you know one thing:
every resource your state file already knows about matches what you
declared.
You know nothing at all about the resources it does not know about.
That is not a gap in your pipeline. It is the shape of the comparison. plan
reads state, asks the provider about each resource in it, and reports
differences. Something that was never in state is never queried, never
compared, and never mentioned. A clean plan and an estate full of hand-made
infrastructure are the same output.
Where unmanaged resources come from
None of these are anybody being careless. They are the normal operation of a team under time pressure:
- The incident fix. A security group rule opened at 02:00 to restore service. The change worked, the incident closed, and the rule outlived both.
- The contractor’s VM. Spun up in the console for a proof of concept six months ago, on a project nobody has revisited since.
- The console default. A managed service that quietly creates its own storage account, its own network interface, its own public IP.
- The dead module. Someone removed a resource block, ran
state rmto make the plan pass, and the cloud object is still billing.
Every one of these is running, reachable, and costing money. None of them will appear in a plan.
How you can actually find them
There are three honest methods, and they differ in what they cost you.
Read the bill. Cost tooling sees every resource, because every resource is billed. It is genuinely comprehensive and it is the wrong shape: it tells you a NAT gateway exists, not that no state file declares it. Good for spend, poor for ownership.
Import everything. terraform import or terraformer against an account,
then diff. Thorough, and expensive enough that it happens once, as a project,
and then does not happen again. The thing you need is a repeating answer.
Compare an inventory against your state. Enumerate what exists in the cloud, enumerate what your state files declare, and treat the difference as the finding. This is the only one of the three that is cheap enough to run on a schedule.
That third method is what “unmanaged resource detection” describes, and it comes with a precondition worth being blunt about.
The precondition nobody mentions
You cannot detect unmanaged resources without first defining managed.
The difference is only meaningful against a state source. Point a tool at a cloud account with no Terraform state connected and every resource is technically undeclared, which is true, useless, and indistinguishable from noise. The answer is only worth reading when the tool knows which state files to count as authority.
That has a practical consequence for scope. A comparison is bounded by what each credential can see: one AWS account, one GCP project, or the Azure subscriptions a credential is granted. Anything outside that boundary is not “clean”, it is unexamined, and a tool that does not distinguish those two is teaching you to trust a number it has not earned.
What a good finding looks like
An unmanaged-resource finding that is worth acting on answers four questions:
- What is it: type, name, and where it lives, precisely enough to find it in a console.
- Which scope: which subscription, account, project or cluster. On a multi-scope estate, two resources with the same name are the normal case, and a finding that cannot tell them apart is a finding you cannot action.
- Why is it unmanaged: no state file declares it, versus a state file declares it and it has since drifted. Different problems, different fixes.
- What now: adopt it into Terraform, delete it, or record a decision to leave it. A finding with no ending becomes a permanent yellow badge that everyone learns to scroll past.
The fourth is the one most tools skip, and it is the one that decides whether anybody is still using the tool in three months.
Adoption is the goal, not the alert
The useful end state for an unmanaged resource is that it stops being one. That means generating the Terraform that would declare it, importing it, and having the finding close because the cause went away, not because somebody dismissed it.
Which is why the honest measure of a drift tool is not how many findings it raises. It is how many of them ended.
Cloudkeel-DD does the third method: it reads your Terraform state or workspaces, enumerates what is live, and reports what nothing declares, per scope, with the adoption path attached. It is self-hosted and read-only: it runs in your own cluster, your credentials stay in your environment, and any remediation arrives as a pull request you review rather than a change it makes for you.
Scans are point-in-time and run on a schedule you set. Unmanaged detection covers an enumerated set of resource types per cloud, and the coverage page lists exactly which, including where the depth ends, because a page that says “everything” is the one you should not believe.
See what it finds in your own estate: one Helm command, no form and no account.