Last updated on
What my Neon spending alert was telling me
Filed under Operations
Neon emailed me at 80% of a $20/month spending threshold. Twenty dollars isn't a number that should be hard to explain for a personal blog, so I sat down to find out where it was going instead of just raising the threshold and moving on.
This site runs several concurrent Claude Code sessions at once, each in its own git worktree, each pointed at its own Neon preview branch. That's the isolation model I wrote up in how I run multiple Claude agents on one repo without them colliding, and it's exactly the kind of setup that can quietly turn into a database bill, the same database I've written about backing up before. It turned out to be the right place to look, just not in the way I expected.
Step one: making sure I was even looking at the right database
The email named a specific org in my Neon account as the one at 80% of its threshold, the org Vercel provisions automatically for this site's database. My Neon MCP connection's default account belongs to a different org entirely, one holding a project for an unrelated app. I'd hit that mismatch before, so I already knew not to trust whatever list_projects returned by default. I had to explicitly ask for the org named in the alert.
That org held exactly one project: this site's production database, provisioned through the Vercel-Neon integration. At least the alert wasn't noise from an unrelated app sharing my account.
Step two: reading branches and computes instead of guessing
Neon's dashboard shows usage, but it doesn't tell you why usage looks the way it does. I pulled every branch and every compute endpoint for the project and looked for anything that didn't match "small blog, occasional traffic."
Two things stood out immediately.
Every compute was sized for a workload this site doesn't have. The project's default compute settings were min: 1 CU, max: 9 CU. Neon bills for whatever a compute is using, and the minimum is a floor: every time a compute is active and not suspended, it's burning at least 1 CU-hour, whether it's serving real traffic or just sitting there between requests.
On Neon's Launch plan that's $0.106 per CU-hour. The production branch alone had logged about 58 hours of active compute time for the billing period. At a 1 CU floor, that's roughly $6.20 just for main sitting idle but awake. At the 0.25 CU floor Neon recommends for this kind of workload, the same 58 hours would cost about a quarter of that.
Every preview branch inherited the same 1-to-9 CU setting, because that's how project defaults work: they're the starting point for anything new.
The project also had 21 branches. Neon's Launch plan includes 10 branches per project; anything past that bills at $1.50 per branch-month, prorated hourly. Six of those 21 were structural: the main branch plus five rolling pre-migration-* snapshots that a script here creates automatically before every production migration, by design, keeping the five most recent as instant restore points. The other fifteen were live preview branches, one per open pull request, courtesy of the Vercel integration creating a database branch for every PR preview deployment.
Six baseline branches plus a working set of concurrent worktree sessions meant this project was often sitting well past the free ten. Unlike compute, branch overage doesn't show up anywhere in the "active hours" numbers. You only see it if you count the branches yourself.
Step three: finding an actual bug
While cross-referencing preview branches against open PRs, I found a handful that didn't match anything open. Their pull requests were closed: some merged days earlier, two of them Dependabot dependency bumps closed that same afternoon. Their Neon branches were still sitting there.
There's a workflow for exactly this. It deletes the preview/<branch> Neon branch the moment a PR closes, merged or not. I went looking for why it hadn't run for those two Dependabot branches and found the answer in a failed run's logs:
NEON_API_KEY came through empty. GitHub restricts repository secrets on pull_request-triggered workflow runs when the PR author is Dependabot. It's a deliberate anti-exfiltration measure, since Dependabot PRs modify manifest files and a malicious dependency bump could otherwise use that access to smuggle secrets out through CI. My cleanup workflow has no idea any of this is happening. It just runs, gets a blank key, and fails.
There's a scheduled sweep that acts as a backstop for exactly this kind of miss, and it was running correctly every day. It just has a deliberate multi-day grace period before it treats an orphaned branch as safe to delete, so the Dependabot ones would sit around accumulating branch-overage charges for days before anything caught them.
The fixes
None of this needed a code change, which was a relief after finding a real bug in something I'd written.
First, repricing the computes. I dropped the project default from 1-9 CU to 0.25-2 CU, then did the same thing to main's existing compute endpoint separately, since project defaults only apply to new computes and don't retroactively touch anything already provisioned. That second step is easy to skip if you don't already know to look for it. Changing the default and assuming it took effect everywhere would have left the one branch that actually matters untouched.
Second, giving Dependabot the secret it needs. GitHub keeps a separate secret store specifically for Dependabot-triggered workflows (Settings → Secrets and variables → Dependabot, distinct from the regular Actions secrets). Adding NEON_API_KEY there was the entire fix. The workflow itself didn't need to change.
What it actually improved
Reading the compute endpoint back after the change confirmed it:
"autoscaling_limit_min_cu": 0.25,
"autoscaling_limit_max_cu": 2,
"current_state": "active",
"suspend_timeout_seconds": 300Same branch, a quarter of the compute floor, same five-minute scale-to-zero behavior it already had. The Dependabot secret fix means the next dependency-bump PR gets its preview branch deleted within seconds of closing instead of riding out the grace window.
Learnings
- A spending alert is a symptom, not a diagnosis. The email gave me a percentage, not a cause. Getting to a cause meant enumerating branches and computes rather than assuming I already knew which part of the setup was expensive.
- Defaults aren't retroactive. Changing a project-level compute default only affects what gets created after the change. If the thing costing you money already exists, you have to go edit it directly.
- Branch-per-preview has a cost dimension that never shows up in compute metrics. Fifteen preview branches at a few minutes of compute each look cheap in CU-hours. They're not cheap in branch-months once they push you past the plan's included branch count, and that number is easy to lose track of when branches come and go automatically.
- Dependabot PRs are second-class citizens for CI secrets, on purpose. Any automation that needs a secret on every PR close, not just human-authored ones, needs that secret in the Dependabot-specific store too, or it will silently fail on exactly the PRs you're least likely to be watching closely.
- A scheduled backstop working correctly can still hide a bug for a long time. The nightly sweep did its job every single day. It just wasn't built to catch this quickly, so a real failure sat there disguised as normal, expected latency.
Comments
No comments yet. Be the first to comment.