The $0 AWS bill
Our invoice read zero for three months. Eighty-one percent of what we were actually spending was a database nobody had connected to since April.
Croplock runs on the box in your house, and that is a fairly recent decision. Until April 2026 there was a cloud backend — a container service, a load balancer, a managed Postgres — and when the product moved on-device the backend was torn down. What was left in the account was a static marketing site, a mail relay, and a handful of things that were supposed to be dormant. The monthly invoice said $0.00, and it said so every month, and nobody looked past it.
A notice that required no action
In late July, AWS sent a routine notice: PostgreSQL 16.10 reaches end of standard support on 31 October 2026. Read literally, it asked for nothing. AWS applies minor upgrades on its own, our engine version was pinned at the major only, and downtime on a database with no clients is not an event. The correct response to the letter, as written, was to file it.
We read it instead as a reason to look at the resource rather than the deadline. That is the whole post, really: a notice about a thing is an occasion to check whether the thing should exist.
What the graph said
The instance’s DatabaseConnections metric was zero across the entire CloudWatch retention window. Not low. Zero. The backend that used to talk to it had been gone since 19 April, and the database had been running — backing up nightly, applying patches, holding a 20 GiB volume — for a client that no longer existed.
It was costing $13.32 a month, which was 81% of everything the account was spending.
Why the bill said zero
Cost Explorer’s default view is unblended cost, and unblended cost nets. The account carried promotional credits, and the credits offset usage exactly: Usage $16.40, Credit −$16.40, total $0.00. The spend only became visible when the report was grouped by record type, at which point the two lines separated and the database stood out immediately.
This is the part worth carrying to any credited account. A $0 invoice on an account with credits does not mean $0 of spend. It means the meter is running somewhere you are not looking. Credits are a countdown, not a discount — they expire, and the resource does not notice when they do. The correct habit is to group by record type, or filter to usage only, and never to trust the headline total while any credit is live.
The teardown
Nine resources in two Terraform applies: the instance, its parameter and subnet groups, a security group and two rules, the password secret and its version, and the random password behind it. Two applies rather than one because deletion protection cannot be switched off in the same apply that destroys the thing it protects — which is the right design, and mildly annoying, in that order. A final encrypted snapshot was kept. The post-apply plan came back clean, and the public site was re-checked over TLS before anyone declared it done. Nothing in DNS, the CDN, storage or mail was touched.
If we ever need Postgres in the cloud again it is a ten-minute apply from history and a snapshot restore. We almost certainly will not; the on-device product does not have a cloud database in its future.
The uncomfortable part
None of this was a discovery. The teardown had been specified in writing on 25 April — the right diagnosis, a cost estimate, the procedure — in a cost-cancellation document written the week the backend was retired. The item sat there as an unchecked box for 96 days, about $43 of credits, until an unrelated notice sent someone back to look.
An unchecked checkbox is not a decision to defer. It is a decision nobody made. The document had done its job and then stopped being read, because the invoice it was supposed to be checked against kept saying zero.
The thing Terraform could not see
Verifying the teardown turned up a second problem. Listing every secret in the account — rather than reading the plan — showed one that Terraform had never managed: a payment-provider configuration value, created by hand in the console in mid-April, in a week when it was faster to paste than to write the resource.
It had survived three separate clean-ups. The payment integration was removed, and its two Terraform-managed secrets were correctly destroyed; this one was not, because it was never in state. The whole backend was retired; it stayed. Every managed secret was renamed for the brand change; it kept the old prefix, which is how it eventually caught the eye. Through all of it terraform plan reported no changes, and it was telling the truth: plan compares configuration to state, and this was in neither.
Two rules follow, and we now treat both as standing:
- A clean plan is not an inventory. Drift that was never in state cannot appear as drift. Periodically enumerate what actually exists per service — list the secrets, describe the instances — and reconcile the list against
terraform state list. - Console-created resources are invisible to infrastructure-as-code forever. The “everything through Terraform” rule is not bureaucracy; it is the only thing that makes the plan mean anything as a record of what you own.
For honesty’s sake: that orphan was deleted with the CLI, not Terraform. Importing a resource for the sole purpose of destroying it is ceremony, and there was nothing left to import afterwards. That is the one shape in which bypassing the rule reduces drift rather than creating it. Creating or changing anything that way remains off the table.
And the runbook
The disaster-recovery runbook was checked in the same pass. Every restore path in it targeted infrastructure that no longer existed. In a real incident it would not have been useless; it would have been actively misleading, sending whoever was on the keyboard toward a database and a service that were not there. It now opens with a stop banner. The honest state is that the hub in the customer’s house is the thing that needs a recovery story, and that document is not yet written.
What we would tell someone starting this
- Group by record type before you believe a cloud bill. Any credit, any time, and the headline is fiction.
- Read a deprecation notice as a prompt to check the resource, not the date. The date is almost never the interesting part.
- Enumerate, don’t plan. A clean plan tells you your config matches your state. It says nothing about what else is in the account.
- A written decision with an unchecked box is not a decision. Put the check against something that gets re-read — a bill that can go non-zero, a metric, a date that fires — not a document that gets filed.
- Re-read the runbook when you tear anything down. A restore procedure for a system you deleted is worse than no procedure.
Sources
All figures are from our own account, read from CloudWatch and Cost Explorer on 2026-07-30 and recorded in the commit that performed the teardown. The backend retirement is our April 2026 architecture decision to run entirely on-device; the cost-cancellation document that specified the teardown is dated 2026-04-25. Dollar amounts are monthly usage before credits. No account identifiers are given, deliberately.