SQLite has been Laravel's default database since Laravel 11 shipped in 2024. Run laravel new, hit enter through the prompts, and your app is talking to a single file on disk. No daemon, no port 3306, no credentials to rotate.
Most reactions to this fall into two camps. One camp treats SQLite as a toy you swap out before your first real user. The other treats it as a silver bullet that makes database servers obsolete. Both are wrong, and both will cost you either money or sleep.
This is the guide for the middle ground: you run Laravel on a VPS, you want fewer moving parts, and you need to know exactly what SQLite demands in production, where it breaks, and when to walk away from it. Real config blocks throughout.
Why SQLite Suddenly Got Respectable
Five years ago, suggesting SQLite for production in a Laravel forum got you laughed out of the thread. Three things changed.
Laravel made it the default. That single decision moved SQLite from "testing database" to "the thing thousands of production apps launched on." The framework followed through: the modern config/database.php exposes SQLite pragmas as first-class connection options, which is most of what production tuning requires.
Modern VPS hardware removed the old bottleneck. SQLite's classic weakness was slow disks. NVMe storage is now standard on entry-level instances. A Hetzner CPX-class box with 4 GB of RAM runs around €7.99/month, against $24/month for the equivalent DigitalOcean droplet, and either one gives you disk latency measured in microseconds. A query against a local NVMe file skips the network hop entirely. There's no connection pool, no TCP handshake, no round trip. A read is a function call into a library living in your PHP process.
Replication tooling matured. The strongest argument against SQLite was always "one file, one disk, one bad day away from losing everything." Tools like Litestream changed that math by streaming every change to S3-compatible storage continuously. We'll set that up below.
None of this makes SQLite the right answer everywhere. It makes SQLite a serious option for a specific shape of application: single server, read-heavy, modest write volume. That shape covers more Laravel apps than most people admit.
The Production Config Laravel Actually Needs
SQLite's out-of-the-box defaults are conservative to the point of being hostile for web workloads. The default journal mode locks the entire database during writes, and the default busy timeout is zero, meaning a second concurrent write throws SQLITE_BUSY: database is locked immediately instead of waiting its turn.
Laravel lets you fix all of this declaratively. Here's the connection block that should be in every production config/database.php:
'sqlite' => [
'driver' => 'sqlite',
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => 5000,
'journal_mode' => 'wal',
'synchronous' => 'normal',
],
Four settings do the heavy lifting:
-
journal_mode => 'wal': switches to Write-Ahead Logging. This is the single most important line. Without it, every write blocks every read. More on what WAL actually changes in the next section. -
busy_timeout => 5000: when a connection hits a lock, it retries for up to 5 seconds before giving up instead of failing instantly. This turns "random 500 errors under load" into "a write occasionally waits a few extra milliseconds." It's the difference between SQLite feeling flaky and feeling boring. -
synchronous => 'normal': the defaultFULLsetting forces an fsync on every transaction.NORMALin WAL mode syncs at checkpoint boundaries instead. In our experience this is the right trade: the database can't corrupt on power loss, though the last few transactions before a hard crash may be lost. If you're running a payments ledger, keepFULL. For everything else,NORMALis noticeably faster. -
foreign_key_constraints => true: SQLite ships with foreign key enforcement off for backwards-compatibility reasons that date to 2009. Turn it on. You want the database rejecting orphaned rows, not your application code hoping to catch them.
One warning that saves people real pain: WAL mode requires a local filesystem. It relies on shared memory between processes, and it does not work correctly on NFS or other network filesystems. If your database file lives on network-attached storage, you've quietly reintroduced the network hop you were trying to eliminate, plus a corruption risk. Local disk only.
What Does WAL Mode Actually Change?
In the default rollback-journal mode, a write transaction takes an exclusive lock on the whole database. Readers wait for writers. Writers wait for readers. On a web server handling concurrent requests, that's a queue of PHP-FPM workers stacking up behind a single UPDATE.
WAL inverts the model. Writes get appended to a separate -wal file, and readers keep reading a consistent snapshot of the main file. The practical result:
- Readers never block writers, and writers never block readers. Your traffic can keep serving pages while a write is in flight.
- There is still exactly one writer at a time. This is the constraint that never goes away. Two simultaneous writes serialize; the second one waits (which is why
busy_timeoutmatters).
For a typical Laravel app, where the overwhelming majority of queries are reads, this constraint is far less scary than it sounds. A blog, a documentation site, a dashboard, a booking tool with a few hundred writes a minute: none of these come close to saturating a single writer on NVMe.
Where write contention actually bites
The apps that hurt on SQLite aren't the ones with lots of users. They're the ones where the framework itself hammers the database with writes. And here's the trap: recent Laravel defaults put the queue, the cache, and sessions in the database. On MySQL that's a reasonable default. On SQLite it means every queued job, every cache write, and every session touch competes for the same single-writer lock as your actual business data.
The fix is to give the chatty infrastructure workloads to Redis or Valkey, which is a one-line install on most VPS setups, and reserve SQLite for the data you actually care about:
DB_CONNECTION=sqlite
DB_DATABASE=/home/deploynix/example.com/shared/database/production.sqlite
QUEUE_CONNECTION=redis
CACHE_STORE=redis
SESSION_DRIVER=redis
With that split, SQLite holds users, orders, and posts, while the thousands of ephemeral writes per minute go to a store built for exactly that. If your queue volume is serious, our Laravel queues guide covers connection and worker tuning in detail. The short version: never point a busy queue at your SQLite file. It works right up until it doesn't, and the failure mode is your whole app slowing down together.
How Far Does One File Actually Go?
Honest answer: further than you think, and we're deliberately not going to invent benchmark numbers here, because SQLite throughput depends almost entirely on your write mix and your disk.
The qualitative framing that holds up in practice:
- Read-heavy apps rarely hit the wall. The SQLite project itself states that any site getting fewer than 100,000 hits per day should work fine with SQLite, and describes that as a conservative estimate. On modern NVMe hardware with WAL mode, read throughput is bounded by your PHP layer long before the database.
- Write-heavy apps hit the single-writer limit. If your core workload is high-frequency inserts from many concurrent sources (event ingestion, chat, real-time analytics), writes serialize and latency climbs. No pragma fixes an architectural mismatch.
- The database size ceiling is not your problem. SQLite handles databases in the tens of gigabytes without drama. You'll outgrow the operational model (one server, one file) long before you outgrow the file format.
A useful mental test: pull up your production query log and count writes per second at peak, excluding cache, sessions, and queue traffic. If the number is in the single or low double digits, SQLite will not be your bottleneck. If it's in the hundreds and climbing, read the graduation section below.
Backups: The Part Everyone Gets Wrong
Here's the trap that catches almost everyone moving SQLite to production: cp database.sqlite backup.sqlite is not a safe backup.
If a write transaction is in flight while the copy runs, you can capture a torn, inconsistent snapshot. Worse, in WAL mode, recent committed transactions live in the -wal sidecar file. Copy only the main file and your "backup" is silently missing the newest data. It will restore. It will look fine. It will be wrong.
SQLite gives you two correct tools instead.
One-shot and scheduled backups
The .backup command uses SQLite's online backup API, which produces a consistent snapshot even while the app is writing:
sqlite3 /home/deploynix/example.com/shared/database/production.sqlite \
".backup '/home/deploynix/backups/production-$(date +\%Y\%m\%d-\%H\%M).sqlite'"
The alternative is VACUUM INTO, which also produces a consistent copy and compacts it at the same time, dropping the dead space left by deleted rows:
sqlite3 /home/deploynix/example.com/shared/database/production.sqlite \
"VACUUM INTO '/home/deploynix/backups/production-$(date +\%Y\%m\%d).sqlite'"
On Deploynix, the practical setup is a scheduled cron job created from the dashboard: run one of the commands above nightly, then push the file to S3-compatible storage with rclone or the AWS CLI, and prune anything older than your retention window. Wasabi or a DigitalOcean Space costs pennies a month at typical SQLite database sizes. Ten minutes of setup, and you have the same offsite-backup posture a managed MySQL setup gives you.
Note the escaped % signs in those commands. In a crontab, a bare % is interpreted as a newline, and the resulting failure is silent. If you paste the command into a shell script that cron invokes, drop the backslashes.
Continuous replication with Litestream
Nightly backups mean you can lose up to a day of data. For anything with real users, run Litestream as well. It sits alongside your app, watches the WAL, and continuously streams every change to S3-compatible storage. Recovery point drops from "last night" to "a few seconds ago."
Install it and drop a config at /etc/litestream.yml:
dbs:
- path: /home/deploynix/example.com/shared/database/production.sqlite
replicas:
- type: s3
bucket: myapp-litestream
path: production
endpoint: https://s3.eu-central-1.wasabisys.com
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
retention: 72h
Litestream needs to run permanently, and this is where a process supervisor earns its keep. The packaged systemd unit works fine, or on Deploynix you can register litestream replicate as a daemon and let Supervisor handle restarts, exactly the way you'd run Horizon.
Disaster recovery is a single command against an empty path:
litestream restore -o /home/deploynix/example.com/shared/database/production.sqlite \
s3://myapp-litestream/production
Do a test restore once, before you need it. A replication pipeline you've never restored from is a hypothesis, not a backup.
The Deployment Gotcha That Deletes Your Database
This is the SQLite production mistake we see most often, and it's brutal because everything works perfectly until your second deploy.
Zero-downtime deployment works by building each release in a fresh directory and atomically swapping a current symlink, which is how atomic deploys are structured on Deploynix and every similar tool. Now think about where Laravel puts SQLite by default: database/database.sqlite, inside the application directory. Inside the release.
Deploy once, and your database is born inside releases/20260713101500/. Deploy again, and the new release gets a brand-new empty file while your real data sits stranded in the previous release directory, waiting for the release-cleanup step to delete it entirely. Users vanish. Orders vanish. It looks exactly like a catastrophic data loss bug, and it's just a path.
The fix is the same pattern used for storage/ and .env: the database lives in the shared directory that survives every deploy, and the app points at it by absolute path.
/home/deploynix/example.com/
├── releases/
│ ├── 20260712091412/
│ └── 20260713101500/
├── shared/
│ ├── storage/
│ ├── .env
│ └── database/
│ └── production.sqlite releases/20260713101500
Then in the shared .env:
DB_DATABASE=/home/deploynix/example.com/shared/database/production.sqlite
Add a deployment hook that guarantees the directory exists before migrations run, so the very first deploy on a fresh server doesn't fall over:
mkdir -p /home/deploynix/example.com/shared/database
touch /home/deploynix/example.com/shared/database/production.sqlite
php artisan migrate --force
Two smaller deployment notes worth knowing. First, migrations that rebuild tables (SQLite implements some ALTER TABLE operations as create-copy-swap) briefly hold the write lock; your configured busy_timeout means in-flight requests wait a few seconds rather than erroring, which is exactly the behavior you want mid-deploy. Second, remember that the -wal and -shm sidecar files live next to the database. They belong in shared/database/ too, and no backup script or deploy hook should ever touch them independently.
When Should You Graduate to MySQL or Postgres?
SQLite in production isn't a religion. It's a phase, and for plenty of apps it's a permanent one. But there are clear, unambiguous signals that the phase is over:
- You need a second app server. This is the hard line. SQLite is a local file; two servers can't share it safely, and the workarounds (network filesystems, distributed SQLite layers) trade away the simplicity that justified SQLite in the first place. The moment horizontal scaling is on the roadmap, plan the migration.
- Sustained concurrent writes are your core workload. If profiling shows writers regularly queuing behind
busy_timeoutat normal traffic, not just during spikes, you've outgrown the single-writer model. - You need read replicas, failover, or point-in-time recovery beyond what Litestream offers. A client-server database makes these first-class features rather than bolt-ons.
- Your team or your compliance auditors need standard database tooling. Access control per user, audit logging, and established DBA workflows all assume a database server.
The migration itself is usually less painful than people fear, provided you stayed inside Eloquent and the query builder. Export with sqlite3 .dump, massage the schema, or more simply: point your migrations at the new connection, run them fresh, and write a one-off command that copies rows table by table. Apps that avoided driver-specific raw SQL typically move in an afternoon.
Choosing what to graduate to is its own decision. Our MySQL vs. MariaDB vs. PostgreSQL comparison breaks that down; the short version is that MySQL is the safe default for Laravel and Postgres wins when you need richer types and querying. Either way, provisioning a dedicated database server next to your app server is a few clicks, and if you're cost-sensitive about running a second box, Hetzner's price-to-performance keeps the graduation cheap.
The Decision Checklist
Run your app through this before committing either way.
SQLite is a strong fit if all of these are true:
- One application server, and no multi-server plans for the next year or so
- Read-heavy workload; peak writes per second in the low double digits or below
- Queues, cache, and sessions are on Redis or Valkey, not the database
- The database file lives on local NVMe, not network storage
- You've set up WAL mode,
busy_timeout, and either Litestream or scheduled.backupjobs - The database file lives in shared storage, outside your releases directory
Pick MySQL or Postgres from day one if any of these are true:
- Load balancer and multiple app servers, now or soon
- High-frequency concurrent writes are the product (ingestion, messaging, telemetry)
- You need replicas, managed failover, or granular database-level access control
- Ops or compliance requirements assume a client-server database
Notice what's not on either list: user count, revenue, or how "serious" the project is. Those are the wrong axes. The right axes are write concurrency and server topology.
The bottom line
SQLite in production for Laravel is neither hype nor heresy. It's a precise engineering trade: you give up multi-server topologies and heavy concurrent writes, and in exchange you get zero network latency, one less service to patch and monitor, and a database that backs up as a single file. For a single-VPS Laravel app with sane write volume, that trade is genuinely good, and the framework's defaults now reflect it.
The production checklist is short and non-negotiable: WAL mode with busy_timeout and synchronous=NORMAL, infrastructure workloads on Redis, .backup or Litestream instead of cp, and the database file in shared storage where atomic deploys can't orphan it. Every one of those steps fits naturally into a Deploynix setup: scheduled crons for backups, Supervisor daemons for Litestream, deployment hooks for the shared path, and one-click rollback for everything else. Get those four things right and one file really does beat a database server, right up until the day it doesn't, and you'll see that day coming from a long way off.
Top comments (0)