750 Free Hours a Month, but a Month Is 730: 3 Free-Tier Mistakes That Took Down My App

# devops# postgres# webdev# buildinpublic
750 Free Hours a Month, but a Month Is 730: 3 Free-Tier Mistakes That Took Down My AppSanjay Kumar Sah

A keep-alive cron, a serverless connection pool that never let go, and one irreversible click. How three reasonable free-tier decisions stacked into a production outage the night before a Play Store release โ€” and exactly how I fixed each one.

๐Ÿšข #BuildInPublic for the RevenueCat Shipaton 2026. RentDera is my Shipaton entry, and I'm sharing the whole journey โ€” the wins and the 5 AM disasters like this one. This is one chapter of that trail.

At 9 PM on September 10th, 2026, Render emailed me to say my production services had been suspended.

At 5 AM on September 11th, I published v1.2.0 to the Play Store.

In between was the worst night of this project so far โ€” and it was entirely my own doing. Three separate mistakes, each individually reasonable, stacked into an outage that took down a shared identity service, a product API, and a mail service at once.

Here's all three, with the numbers.

Render suspension email

The 30-second version (if you only read this far)

  • Free tiers are metered. Render gives ~750 machine-hours/month across a workspace; a month is ~730 hours. Keeping one service awake 24/7 eats almost the entire budget.
  • A keep-alive cron is a trap. Pinging services to stop them sleeping doesn't outsmart the platform โ€” it just spends the free quota faster, and then everything gets suspended.
  • On a serverless database, min_connections: 0 is mandatory. One idle connection held open keeps the database awake 24/7 and can nearly double your usage at zero traffic.
  • Never bake a platform's URL into a mobile app. Use a domain you own, so you can move servers without shipping a new app build.

If you're new to this, don't worry โ€” I explain every term below in plain language.

Jargon, in one line each:
Free tier = the no-cost plan, with usage caps. ยท Cold start / spin-down = a sleeping server takes a few seconds to wake. ยท Cron job = a task on a timer. ยท Connection pool = a small set of reusable database connections. ยท Serverless / scale-to-zero database = a database that sleeps when idle and bills by the second. ยท Compute Unit (CU) = Neon's unit of database processing time. ยท Hostname / subdomain = the address in a URL, like auth.notils.com.

The setup

I'm a solo developer. The stack is deliberately cheap:

  • Three Rust backend services โ€” a shared identity service, a product API, and a transactional email service
  • Postgres on Neon's free tier
  • A React Native + Expo mobile app, in Play Store closed testing with 20 testers
  • Everything on Render's free tier, in one workspace

Free tier everywhere. That was the point. There's no revenue yet, and 20 testers don't justify a hosting bill.

Mistake #1: the keep-alive cron

Render's free services spin down after ~15 minutes of inactivity. The next request pays a 30โ€“60 second cold start.

For 20 testers, that's a genuine problem. A landlord opens the app, waits 40 seconds, and concludes the app is broken. So I did the obvious thing: a cron job pinging every service every 5 minutes to keep it awake.

It worked perfectly. That was the problem.

Render's free allowance is 750 instance-hours per month, per workspace โ€” shared across every free service in it.

A calendar month is about 730 hours.

Read those two numbers again. The free tier is sized so that one always-on service consumes the entire monthly allowance with 20 hours to spare. It is not sized for two. The spin-down isn't a defect you work around โ€” it's the mechanism that makes the arithmetic work at all.

I had four services (production and staging for two of them), all pinned awake by my own cron:

4 services ร— 24 hours/day = 96 instance-hours per day
750 รท 96 โ‰ˆ 7.8 days
Enter fullscreen mode Exit fullscreen mode

Just under eight days to burn a month's allowance. Mine lasted about ten, because the cron didn't catch every service on every pass. Not much longer.

And when the allowance runs out, Render suspends every free service in the workspace. Not the greediest one. All of them. Staging and production together.

The lesson: before you defeat a platform's idle timeout, check what the idle timeout is paying for. If the free quota is smaller than a calendar month, sleeping is not optional โ€” it's the business model.

Mistake #2: one connection, held forever

Then Neon emailed me too. 100 compute-unit hours on the free plan, and I was at 80%.

Neon usage / compute-hours warning email (the

This one took longer to find, because the culprit was four lines in a config struct I'd written weeks earlier and never looked at again:

DbConfig {
    max_connections: 10,
    min_connections: 1,                        // โ† this line
    connect_timeout: Duration::from_secs(5),
    acquire_timeout: Duration::from_secs(5),
    idle_timeout: Duration::from_secs(600),
    max_lifetime: Duration::from_secs(1800),
}
Enter fullscreen mode Exit fullscreen mode

min_connections: 1 is a completely ordinary pool setting. It means: always keep at least one connection open, so the first request after a quiet period doesn't pay for a TCP handshake and TLS negotiation. On a traditional Postgres box, it's free. You'd have no reason to think about it.

On serverless Postgres, it's a standing charge.

Neon (and Supabase, and PlanetScale, and every other scale-to-zero database) suspends your compute after a few minutes of inactivity. But it cannot suspend while a client is still connected. A pool that always holds one open connection keeps the compute awake 24 hours a day, at zero traffic.

0.25 CU ร— 730 hours = ~182 CU-hours/month
Free tier allowance:     100 CU-hours/month
Enter fullscreen mode Exit fullscreen mode

Nearly twice the free allowance, burned by a service nobody was using. And idle_timeout: 600 didn't save me โ€” the pool closes an idle connection after 10 minutes and then immediately opens a new one to satisfy the minimum.

The fix is four lines:

DbConfig {
    max_connections: 10,
    min_connections: 0,                        // let the last connection actually close
    connect_timeout: Duration::from_secs(10),  // absorb a suspended compute resuming
    acquire_timeout: Duration::from_secs(10),
    idle_timeout: Duration::from_secs(60),     // well under Neon's ~5-min suspend timer
    max_lifetime: Duration::from_secs(1800),
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting beyond min_connections: 0:

  • idle_timeout has to be well below the provider's suspend timer. Closing your connection after 10 minutes when the provider suspends at 5 means you never suspend. It has to drain first.
  • Raise your timeouts. Once the compute does suspend, the next request has to wait for it to wake up. A 5-second budget that was generous for a warm connection can be tight for a cold resume. Scale-to-zero trades a standing charge for occasional latency โ€” you have to actually budget for the latency.

All three of my services were generated from the same template, so all three had the identical bug on the identical line. If you run a service template, a default like this propagates silently into everything you'll ever generate from it.

The lesson: connection pool defaults were written for servers that are always on. If your database bills by the second and sleeps when idle, min_connections: 0 isn't a tuning preference. It's a correctness requirement.

Mistake #3: the one I can't undo

The first two mistakes cost me money I didn't spend and a night I didn't sleep. This one cost me something I can't get back.

While cleaning up, I deleted the suspended Render service.

Render assigns every service a unique *.onrender.com address. Delete the service and that address is gone permanently โ€” you cannot recreate it, and a new service gets a new unique name. There's no "restore".

My mobile app v1.1.0 โ€” already in the hands of closed testers, already through Play Store review โ€” had that address compiled into the binary.

v1.1.0 is now permanently dead. Not degraded. Not slow. Requests to the old hostname return:

HTTP/1.1 404 Not Found
x-render-routing: no-server
Enter fullscreen mode Exit fullscreen mode

There is no configuration change, no server-side fix, and no rollback that can save it. The only repair is a new build, a new review, and every tester updating.

That's why I was publishing at 5 AM.

RentDera changelog / release notes for v1.2.0

The actual root cause wasn't the deletion

It was that a hostname I didn't own was baked into a binary I couldn't hot-fix.

A mobile app is the least forgiving consumer you will ever have. A web frontend redeploys in a minute. A backend service reads an environment variable and restarts. An app in a store is frozen the moment it ships, and unfreezing it costs a review cycle measured in hours or days.

So v1.2.0 ships pointing at domains I control:

โŒ  my-service-a3f9.onrender.com     โ† platform's address, platform's to revoke
โœ…  auth.notils.com                  โ† mine, points wherever I say
Enter fullscreen mode Exit fullscreen mode

Now migrating providers is a DNS change. Testers notice nothing. The app doesn't need to know where the server lives, and that was true the whole time โ€” I just hadn't made it true in the config.

The lesson: anything compiled into a store binary must be a name you own. Not a convenience URL. Not a platform subdomain. A hostname on a domain you control, from the very first build that reaches another human being.

One detail that nearly caught me a second time: I picked hostnames one label deep โ€” auth.notils.com, not api.auth.notils.com. Cloudflare's free Universal SSL covers example.com and *.example.com, one level only. A deeper subdomain needs Advanced Certificate Manager at $10/month per zone, which is more than the server. Free-tier constraints show up in the strangest places.

Verifying it actually worked

Two production services are now live on owned domains, both fronted by Cloudflare and proxying to a Render origin:

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://auth.notils.com/health
200 0.31s

$ curl -s https://auth.notils.com/.well-known/jwks.json
{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"โ€ฆ","x":"โ€ฆ","y":"โ€ฆ"}]}
Enter fullscreen mode Exit fullscreen mode

Three things I checked, and would check again:

  1. /health/ready, not just /health. Liveness only proves the process is up. Readiness does a database round-trip, which is what actually proves the pool change didn't break the cold-resume path.
  2. The JWKS response contains no d field. On an identity service, d is the private key component of an EC key. A JWKS endpoint that leaks it hands over the ability to mint tokens. It's a one-line check and it should be in your deploy runbook forever.
  3. The response headers. CF-RAY: โ€ฆ-KTM told me Cloudflare was terminating TLS at a Kathmandu edge โ€” in-country for my users โ€” while x-render-origin-server: Render confirmed it was reaching the right origin. Useful to know before optimizing anything about where the server itself lives.

The economics nobody writes about

The obvious response to all this is "just pay for hosting." Let's do that math honestly.

Render's paid tier is $7/month per service. I need three services in production, so $21/month, every month, before the database or anything else.

A VPS with more RAM and more cores than all three services combined is cheaper per month โ€” but the advertised price usually requires committing to one to four years upfront. The monthly rate is often double, and renewal rates are higher still. "Cheaper per month" and "affordable this month" are not the same sentence when you're a solo developer with no revenue.

So the real options for a pre-launch solo project are:

  1. Pay $21/month indefinitely for 20 testers
  2. Prepay a year or more for a VPS and become your own sysadmin
  3. Make the free tier actually work

I picked option 3, but properly this time: a new workspace, production services only, no staging, no keep-alive cron, a pool that drains to zero, and custom domains in front of everything. The spin-down is back, and testers will occasionally wait a few seconds on first open. That's an honest trade for $0, and it's reversible โ€” the day upgrading makes sense, it's a dashboard toggle and nothing else changes, because nothing downstream knows where the service lives.

The staging environments are simply gone for now. That's a real cost, not a clever saving: I test against production and I'm careful. It buys back the headroom that keeps production inside the free allowance, and it's the first thing I'll restore when there's a budget.

What I'd tell myself two weeks ago

  1. Read the quota arithmetic before designing around it. 750 hours/month sounds generous until you divide by a 730-hour month.
  2. Never add a keep-alive ping to a metered free tier. You're not outsmarting the platform, you're spending the budget faster.
  3. Set min_connections: 0 on any serverless database. Check this today if you're on Neon, Supabase, or PlanetScale with a pooled client. It's four lines and it may be your entire bill.
  4. Own the hostname before the first store release, not after.
  5. Deleting a resource on a platform that assigns unique names is irreversible. Read the dialog. I didn't.
  6. Don't miss the first warning email. Render and Neon both warned me before suspending. I caught the second one, at 9 PM, after the damage.

It isn't finished

In the interest of not writing a tidier ending than I earned: the third service โ€” the one that sends transactional email โ€” is still down while I work through this. Both of its addresses return 404.

Its two call sites fail differently, and one of them fails silently. The email-verification endpoint propagates the error, so a client gets a real 500 and can tell the user to retry. But the forgot-password endpoint deliberately swallows delivery errors โ€” because an endpoint that behaves differently for registered and unregistered addresses is an account-enumeration oracle. Correct security design. It also means a real user can request a password reset right now, see "check your email," and wait forever.

That's next. But v1.2.0 is live, the testers are unblocked, and the two root causes are fixed in all three services.

I'll take it.


If you're running a free-tier stack, go check min_connections right now. It'll take two minutes and it might be the most expensive line in your codebase.

This is part of **Building RentDera in Public* โ€” my journey shipping a rent-management app for the RevenueCat Shipaton 2026. Follow the series for the next chapter (including whether the email service ever comes back). If this saved you a bad night, drop a comment with the free-tier gotcha that bit you. ๐Ÿšข*

Built with Rust ยท Neon ยท Render ยท Cloudflare ยท React Native + Expo. Full RentDera changelog: https://rentdera.com/changelog. #BuildInPublic #Shipaton