Why SQLite Works in Production: A Laravel Case Study
SQLite in production is not a toy. Here is how one Laravel site runs on it at zero marginal cost, with real backup and concurrency strategy.
The reflex is automatic: if it is production, it must be PostgreSQL or MySQL. A personal site with an admin panel, an API, and scheduled jobs does not need a database server. It needs SQLite and a deployment strategy that treats the single file as the asset it is.
This is how martinfournier.com runs. One file, one volume, zero orchestrator.
Why Not PostgreSQL
The honest answer: because the site does not need it. No replication, no connection pooling, no concurrent writers beyond the occasional background job hitting the same database that the web process uses. The site serves fewer than 500 requests per hour. Provisioning a managed Postgres instance on Fly.io costs $15/month minimum. SQLite costs nothing and is already there.
The dishonest answer would be that it is simpler. It is not simpler in every dimension. Backups require thought. Concurrent writes require explicit handling. Schema migrations require a strategy for a file that could be gigabytes.
But the trade is worth it when you are the only operator.
WAL Mode Is Non-Negotiable
SQLite ships with a journal mode that blocks readers during writes. That is a showstopper for any web application. The fix is one PRAGMA:
PRAGMA journal_mode=WAL;
Write-Ahead Logging decouples readers from writers. Readers see a consistent snapshot of the database. Writers append to the WAL file. Checkpointing merges the WAL back into the main database automatically or on demand.
This turns SQLite from a single-user embedded database into something that comfortably handles a Filament admin panel, API requests, and a scheduler worker running concurrently. Laravel applies this PRAGMA automatically at connection time when the database config points to a SQLite store, but verifying it on deployment is worth the one-liner.
Busy Timeout: The Invisible Safety Net
The second PRAGMA that every production SQLite connection must set:
PRAGMA busy_timeout=5000;
Without it, a write attempt during another write returns SQLITE_BUSY immediately. With it, SQLite spins for up to five seconds before giving up. Laravel wraps this in PRAGMA busy_timeout inside the SQLite connector, but the default is zero. Override it explicitly in config/database.php:
sqlite => [
driver => sqlite,
url => env(DATABASE_URL),
database => env(DB_DATABASE, database_path(database.sqlite)),
prefix => ,
foreign_key_constraints => env(DB_FOREIGN_KEYS, true),
busy_timeout => 5000,
],
This single value prevents more 500 errors than any caching layer would on a site this size.
Backups Without Downtime
Backing up a file while it is being written produces a corrupted copy. The standard solution is the .backup command in the SQLite CLI, which acquires a shared lock and streams a consistent snapshot:
sqlite3 /path/to/database.sqlite .backup /tmp/backup-$(date +%Y%m%d-%H%M%S).sqlite
For Fly.io deployments, this runs as a weekly cron job inside the VM volume. The backup file lives in the same persistent volume as the database. This does not protect against volume failure. For that, the backup needs to leave the VM. A simple approach is to pipe the backup through gzip and upload it to an S3-compatible object store:
sqlite3 /path/to/database.sqlite .backup /dev/stdout | gzip | \
aws s3 cp - s3://bucket/db-backups/$(date +%Y%m%d-%H%M%S).sqlite.gz
One environment variable for the bucket, one cron entry, zero downtime, zero third-party database services.
Schema Migrations on SQLite
SQLite has ALTER TABLE support, but it is limited. You can add columns and rename tables. You cannot drop columns, add foreign keys, or change a column type without recreating the table.
Laravel migrations handle this transparently most of the time. The edge cases surface when a migration tries to add a ->change() modifier or drop an index that does not exist. Test migrations locally before deploying. The error surface of a failed SQLite migration is a locked database and a broken site.
The safest pattern: write additive migrations. Add columns, create tables, build indexes. Avoid destructive changes on production databases. If you must drop a column, use a multi-step migration: add the replacement, backfill data, stop writing to the old column, remove it in a later deployment.
When SQLite Is Not Enough
SQLite breaks when you need concurrent writers at scale. Multiple web processes, each running their own write-heavy requests, will collide. The busy timeout masks the problem until the retry budget is exhausted and requests start failing.
The threshold is lower than most developers expect. A single Filament admin panel with two concurrent users editing different records will never hit it. A queue worker processing 200 jobs per minute might. The fix before reaching for Postgres is to route all writes through a single process or serialize them through the queue. If that is not enough, migrate.
For martinfournier.com, it has been enough for six months. The database file is 12 MB. The WAL file rarely exceeds 1 MB. Backups take 0.4 seconds. The site has never returned a 500 error from a database timeout.
The Volume Architecture on Fly.io
Fly.io mounts a persistent volume at /data in the VM. The SQLite file lives there. The Dockerfile copies the schema from the build stage, and the entrypoint runs php artisan migrate --force on every deploy. This means the first deploy after a database wipe creates an empty database with the correct schema. Subsequent deploys apply any new migrations.
The volume is a single-attachment SSD. You cannot scale it horizontally. You do not need to. A single 1 GB volume costs $0.15/month and will outlast the site.
Key Takeaway
SQLite in production is not a compromise. It is a deliberate choice for applications that fit its concurrency profile. The operational surface is smaller than a database server, the cost is zero, and the discipline it enforces (test migrations, additive schema changes, explicit backup strategy) is good practice regardless of the database engine.
Do not reach for Postgres because you think SQLite is not production-ready. Profile the workload first. Measure the contention. If the concurrency fits inside a WAL journal and a five-second busy timeout, SQLite is the right tool.