Shipping SQLite to production on purpose

A restaurant client’s bookings app runs SQLite in production, on purpose: one Docker container with the database file on a persistent volume, instead of the Postgres migration the issue tracker assumed. The deciding inputs were write concurrency measured in bookings per day and a test harness I didn’t want to rewrite — not performance.

The default was Postgres, and the ticket said so

For most of the build, the project tracker carried an issue titled “Postgres production migration”, created on the unexamined assumption that SQLite was a dev-only convenience. Nobody had decided Postgres; it was just the gravitational default for “a real app in production”. When deployment actually arrived, I examined the assumption: this is a single-merchant app for one restaurant — a dining room and two boardrooms as bookable resources, seven data models, a 15-minute hold on pending bookings. Its write load is one restaurant’s worth of reservations. The issue was closed by a decision, not a migration.

The entire deployment is a container and a volume

The app deploys to Railway from a Dockerfile. The container boot command runs the schema migrations and then starts the server, so a deploy and a migration are the same event. Railway offers no managed SQLite and its filesystem is ephemeral, so persistence comes from one mounted volume: DATABASE_URL=file:/data/prod.sqlite. Seeding is guarded — it runs only against an empty database, which means a redeploy can never rewrite live resource IDs, and a deliberate clean re-seed is “delete the database file and redeploy”, an operation that is only safe precisely when there is no real booking data to lose. The first deploy went live end-to-end — customer widget to paid booking — the same day the decision was made.

What it cost

Three things, all real. Backups became my responsibility: the platform’s volume backups are configured on a weekly schedule plus a manual baseline, where a managed Postgres would have given point-in-time recovery without a decision. One writer means one instance: the volume attaches to a single service, so horizontal scaling is off the table — irrelevant at this load, but it’s a wall, and it’s worth knowing where the wall is before choosing to build toward it. And operational muscle memory doesn’t transfer: nobody’s runbook says “the production database is a file”, so the re-seed rule and the backup story had to be written down rather than assumed.

What it didn’t cost

The test harness survived untouched: every test spins up a throwaway SQLite file and deletes it, which is fast, parallel-safe, and — because production now runs the same engine — exact. Dev, test, and production all execute the same SQL against the same database engine, which deletes an entire class of “works locally” bugs instead of managing it. There is no connection pool, no max_connections tuning, and no managed-database line item on the hosting bill. And the exit remains open: the schema lives in Prisma and is fully portable, so the deferred Postgres migration is still available — it’s just no longer a prerequisite for shipping.

When this is the right call

The heuristic I took from it: choose the database for the write concurrency you actually have, not the write concurrency that would make the architecture diagram impressive. A single-tenant app with one logical writer and a human-scale event rate — bookings, not telemetry — sits well inside SQLite’s envelope, and the things Postgres buys (concurrent writers, managed failover, horizontal read scale) were all solving problems this app doesn’t have. The cost that mattered was never query performance; it was who owns backups and where the scaling wall stands. Write those two down, and a file on a volume is a perfectly honest production database.

Revisions

  1. Published.
  2. Created; body written from the project's deployment records.