Where it runs, what that costs, and what was ranked and rejected — so nobody re-researches this at 1am.
Live at https://openinnings.com since 17 August 2026. Heroku, EU
(Ireland), auto-deploying from master.
| Piece | Choice | Cost |
|---|---|---|
| Compute | Heroku Basic dyno | $7/mo |
| Database | Heroku Postgres Essential-0 | $5/mo |
| DNS + certificates | Cloudflare (free) + Heroku ACM | $0 |
| Domain | openinnings.com |
~$1/mo |
| Total | $12/mo |
Covered by the GitHub Student Developer Pack — $13/month of credit for 24 months, so the bill is zero until roughly August 2028.
Use Basic, never Eco. Eco sleeps after 30 minutes, and the whole growth loop is a stranger opening a shared scorecard link. A cold start on that link is the one place this app cannot afford to be slow.
The pnpm-monorepo friction that normally makes Heroku painful doesn't apply:
packageManager: [email protected]— the Node buildpack reads it and uses pnpm- root
build→pnpm --filter web build, so no monorepo buildpack is needed - root
start→pnpm --filter web start;next starthonours$PORT
Heroku also compiles on a separate build dyno with more memory than the
runtime dyno, which removes the single biggest obstacle to the cheap
alternatives below: next build needs 2–4GB and gets OOM-killed on a 1GB VM.
There is no India region. US (Virginia) or EU (Ireland) only, so ~150–200ms from north-east India against ~30ms to a Mumbai or Hyderabad box. The scorer POSTs every ball, so that lag is felt on all 240 taps of a T20.
The mitigation is offline-first scoring, which is built — a tap writes to SQLite and to the screen and returns, and a drain loop handles the network. It was needed regardless, because grounds have patchy signal or none. Hosting in India would have let that keep being postponed.
ball_events is the only table that grows fast. Everything else — users,
teams, players, matches, innings — stays tiny regardless of user count.
- One
ball_eventsrow: seven UUID columns plus small ints, enums, booleans, a timestamp and index overhead ≈ 250–300 bytes - A T20 match, both innings, extras and wickets included ≈ 280 rows ≈ 80KB
- A genuinely active season — 200 weekly scorers over six months — is 200 × 26 × 280 ≈ 1.45M rows ≈ 400–450MB in year one
That number is why the free managed-Postgres tiers were rejected: Neon and Supabase both cap around 500MB, which this reaches at a level of activity well short of what "1,000 users" sounds like. Essential-0's 1GB is roughly two years of heavy use; a self-hosted VM's 200GB is effectively unbounded at this scale.
Storage is the constraint, not compute. Traffic is bursty — weekend cricket, not constant SaaS load.
Essential-tier Postgres has no rollback and no continuous protection; those start at Standard. So the only thing between a bad migration and a lost season is a dump somebody remembered to take.
That matters more here than in most apps. Every figure — every career average,
every scorecard, every share card — is derived from ball_events. Losing it is
not losing a cache that can be rebuilt. It is losing the match.
pnpm db:backup # a local .dump, off Heroku entirely
heroku pg:backups:capture -a open-innings # Heroku's own, short retentionUse both, for different jobs: Heroku's for "undo the last hour", the local one for "keep a copy somewhere Heroku cannot lose". Run one before every schema change. Restoring is:
pg_restore --clean --no-owner --dbname "$DATABASE_URL" backups/<file>.dumpThe dump is custom-format, so a single table can be restored from it — which is what you actually want when one table is wrong and the rest is fine.
release: pnpm db:migrate
web: pnpm start
The Procfile's release phase applies pending migrations on every deploy, and
a failed release aborts the deploy rather than leaving a half-migrated app
serving traffic.
Never db:seed in a release phase. It creates dev@local with a password
published in this repository. The script refuses non-local databases, but the
right answer is not to invite it.
- Region is Europe, chosen at creation and unchangeable. It is the closest Heroku region to India.
- Basic dyno, never Eco. See above.
- TLS is decided in code, not on the connection string. Heroku rewrites
DATABASE_URLon credential rotation, so a hand-appended?sslmode=requiresilently disappears. Seelib/db/ssl.ts. - Connections. Essential-0 allows 20;
lib/db/client.tsusesmax: 10. Fine on one dyno, at the ceiling on two. - ACM is not automatic. Adding a domain does not issue a certificate — Settings → Configure SSL → Automatic Certificate Management. Until then the domain refuses connections with no obvious cause.
- Cloudflare records must be grey (DNS only). Proxied records stop Heroku validating the domain, so the certificate never issues.
NEXT_PUBLIC_*is inlined at build time. Changing it on a running app does nothing until the next rebuild — the dashboard shows the new value while the app serves the old one.APP_URLis read at runtime instead.- HTTP does not redirect by itself. Next does not, and Cloudflare cannot
while records are unproxied. Handled in
next.config.tsoffx-forwarded-proto.
An Oracle Cloud VM.Standard.E2.1.Micro (openinnings-prod, Hyderabad)
was provisioned on 16 August and is kept running. Free, India-region, and the
reason this project is never trapped: if Heroku's latency hurts or the credit
lapses, there is already a machine to move to.
If you are self-hosting this yourself, the whole thing is one Postgres database and one Node process:
pnpm build && pnpm db:migrate && pnpm startThere is no ad server, no analytics vendor and no third-party auth in your own build.
pnpm build gets OOM-killed on 1GB. next start only needs ~200–400MB, so:
CI: pnpm install → pnpm build → upload the .next artifact
Server: pull the artifact → pnpm start
This is worth doing regardless of RAM: builds become reproducible, and a broken build fails in CI rather than halfway through a production deploy.
Defaults assume a much larger machine.
shared_buffers = 128MBwork_mem = 4MBmax_connections = 20— the app pools; it does not need 100- Add 2GB of swap — not for normal running, but as a cliff-edge guard so a spike degrades instead of getting the process killed
Checked August 2026. The binding constraints are: an India region (every ball POSTs from a ground), always-on (a shared link must open instantly for a stranger — no sleep timers), and a real Postgres that doesn't expire.
| Option | India? | Verdict |
|---|---|---|
| Heroku (Student Pack) ✅ | No | Running. $13/mo credit covers a $12 bill for 24 months |
| Oracle E2.1.Micro ✅ | Yes | Provisioned, held as the fallback. Free, no card |
| Azure for Students | Yes | Best paid-credit fallback — $100, no card, Central India |
| AWS Lightsail Mumbai | Yes | $5/mo, simple, no free tier |
| Linode / Vultr / DO | Yes | $5–6/mo. All 1GB, so build off-server |
| Home machine + CF Tunnel | Yes | Genuinely works. Uptime risk makes it a staging box, not this |
| Google Cloud free e2-micro | No | Perpetually free but US-only regions → 200ms+ |
| Hetzner CX22 | No | Best value anywhere at €3.79 — but ~130ms |
| Neon / Supabase free | — | ~500MB cap. See the sizing math above |
| Render / Railway / Fly | — | Sleep timers, expiry, or paid |
| Vercel Hobby | — | Non-commercial terms, serverless, and hosts no database |
Oracle's Ampere (A1) shapes are the trap. Every attempt returned "Out of
capacity" in Hyderabad — the well-known ARM shortage on Always Free accounts,
not a misconfiguration. E2.1.Micro succeeded immediately, which is the useful
finding: the region wasn't full, only Ampere was. Try E2 before concluding a
region is full.
Three escapes that do not work, all suggested by Oracle's own error page: a different availability domain (Hyderabad has exactly one), a different region (Always Free lives only in your home region, fixed at signup), and upgrading to Pay-As-You-Go (which removes the guarantee of never being charged).
The general lesson, kept because it generalises. People run A1 retry loops for weeks. If a deploy is ever blocked again with a date approaching, set a dated line and act on it rather than waiting hopefully. "I don't want to pay" must not quietly become "I ran out of time".