# Shipping SQLite to production on purpose

Yes, SQLite works in production for a single-tenant app: a restaurant bookings system I built runs it on a Docker volume instead of Postgres. What it cost: backups became my responsibility, one writer means one instance, and a clean re-seed means deleting the database file. What it didn't: the per-test throwaway-database harness, dev/prod engine parity, and the connection-pool layer I never added. The deciding input was write concurrency — one restaurant's bookings, not thousands of writes a second.

Published: 2026-08-31
Canonical: https://umar.codes/sqlite-in-production

## Revisions

- 2026-07-30 — Created; body written from the project's deployment records.
- 2026-08-31 — Published.

---

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.
