SQLite in Production for Laravel: WAL, Busy Timeouts and Litestream

Run Laravel SQLite in production: WAL and busy_timeout pragmas, split queue databases, Litestream replication to S3, a tested restore, and the exit plan.

Steven Richardson
Steven Richardson
· 12 min read

SQLite is the connection a fresh Laravel app ships with, and almost nobody deploys it. The folk wisdom is "it locks" — and it does, out of the box, because the defaults assume one process holding one file, not PHP-FPM plus three queue workers plus a scheduler. For a single-server app with modest write volume, what stands between you and a database with no server, no connection pool and no network hop is about twenty lines of configuration and a rehearsed restore.

Decide whether SQLite fits your deployment#

Before any configuration, be honest about the shape of the application. A Laravel SQLite production deployment is defensible when you run one application server, writes are modest and bursty rather than sustained, and operational simplicity is worth real money to you. Internal tools, a SaaS with a few hundred tenants, a content site with an admin panel — all fine.

It is the wrong call when you run more than one app server (the file cannot be shared over a network filesystem safely), when you need read replicas, when write concurrency is sustained rather than bursty, or when anything in your stack expects a database host it can connect to. If you are already thinking about read replicas and sticky reads, you have outgrown this article.

The ceiling is not reads. SQLite will serve tens of thousands of reads per second from a warm page cache. The ceiling is the single writer, and the rest of this article is mostly about living within it.

Set the pragmas in config/database.php#

Laravel exposes the pragmas that matter as connection options, so they are applied on every connection rather than once in a migration. Edit the sqlite connection in config/database.php:

'sqlite' => [
    'driver' => 'sqlite',
    'url' => env('DB_URL'),
    'database' => env('DB_DATABASE', database_path('database.sqlite')),
    'prefix' => '',
    'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),

    // Readers no longer block the writer. Persistent: written into the file header once.
    'journal_mode' => 'WAL',

    // Wait up to 5s for a lock instead of failing immediately. Per-connection.
    'busy_timeout' => 5000,

    // Durability trade: fsync at checkpoints, not every commit. Per-connection.
    'synchronous' => 'NORMAL',
],

Three of these need explaining, because only one of them is persistent.

journal_mode is written into the database file header and survives reconnects — set it once and every future connection inherits it. busy_timeout and synchronous are per-connection settings, which is exactly why Laravel runs them as pragmas each time it opens a connection. Setting them by hand in a migration does nothing for the next request.

synchronous = NORMAL under WAL is the usual recommendation and it is a genuine trade, not a free win. You keep durability across an application crash but risk losing the most recent transactions on sudden power loss or a kernel panic, because commits are no longer fsynced individually. On a managed VPS with battery-backed storage I take that trade. On hardware you do not trust, leave it at FULL and accept slower writes.

The two remaining pragmas worth knowing are cache_size and mmap_size. Laravel has no config key for them, so set them on connect:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    if (DB::connection()->getDriverName() === 'sqlite') {
        // Negative values are KiB, so -64000 is roughly 64 MiB of page cache.
        DB::statement('PRAGMA cache_size = -64000');

        // Memory-map up to 256 MiB of the database for read-heavy workloads.
        DB::statement('PRAGMA mmap_size = 268435456');
    }
}

Both are per-connection. On a read-heavy app the page cache is the setting you will feel.

Reproduce "database is locked", then confirm the fix#

Do not take the fix on faith. The mechanics are simple: SQLite permits one writer at a time, a second writer that finds the lock held returns SQLITE_BUSY immediately, and Laravel surfaces that as Database is locked. With busy_timeout unset, "immediately" means after zero milliseconds of waiting for a lock that would have cleared in two.

Reproduce it with two parallel artisan processes hammering writes. Set journal_mode back to delete and busy_timeout to 0, then:

// routes/console.php
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

Artisan::command('stress:writes {count=500}', function (int $count) {
    $failures = 0;

    for ($i = 0; $i < $count; $i++) {
        try {
            DB::table('jobs_stress')->insert([
                'payload' => str_repeat('x', 256),
                'created_at' => now(),
            ]);
        } catch (\Illuminate\Database\QueryException $e) {
            $failures++;
        }
    }

    $this->info("failures: {$failures} / {$count}");
});

Run four of them at once:

for i in 1 2 3 4; do php artisan stress:writes 500 & done; wait

On the rollback-journal defaults I see failure counts in the low hundreds per process. Flip journal_mode to WAL and busy_timeout to 5000, truncate the table and run it again: zero failures, with wall time up slightly because contending writers now wait rather than explode. That is the entire trade, and it is the right one.

One caveat the pragma does not cover. SQLite starts transactions in deferred mode, so a transaction that reads first and writes later can still fail with SQLITE_BUSY when it tries to upgrade its lock and another writer got there first — busy_timeout does not help with that upgrade. Keep write transactions short, do the read outside the transaction where you can, and wrap genuinely contended work in a retry.

Split queues, cache and sessions off the application database#

This is the mistake that makes people give up on SQLite. Pointing QUEUE_CONNECTION=database, CACHE_STORE=database and SESSION_DRIVER=database at the same file means your queue workers, your cache writes and every authenticated request are all competing for the one writer — against your actual application traffic.

The cheap fix is separate files. Each SQLite database has its own writer, so separating by concern separates the contention:

'sqlite' => [
    // ... as above, application data
],

'sqlite_queue' => [
    'driver' => 'sqlite',
    'database' => database_path('queue.sqlite'),
    'prefix' => '',
    'foreign_key_constraints' => false,
    'journal_mode' => 'WAL',
    'busy_timeout' => 10000, // workers can afford to wait longer
    'synchronous' => 'NORMAL',
],

Then QUEUE_CONNECTION=database with 'connection' => 'sqlite_queue' in config/queue.php. Cache and sessions are better off out of SQLite entirely — file or array for cache on a single box, or Redis/Valkey if you already run one. If you go that way, the Redis to Valkey migration guide covers the driver configuration.

Worker count matters more here than on Postgres. Two or three workers against a dedicated queue database is fine; twelve is not, regardless of how many cores you have. The Supervisor configuration for Laravel queue workers is the right place to cap numprocs, and if you are running workers in containers, the Docker healthcheck for queue workers keeps a wedged worker from quietly holding a write lock.

Replicate the database continuously with Litestream#

A file on a disk is not a backup. Litestream watches the WAL and ships changes to object storage continuously, which turns the file into something you can restore to a point in time. Version 0.5 changed the configuration shape — each database now takes a single replica block rather than the deprecated replicas array — so ignore any guide still using the old format.

# /etc/litestream.yml
access-key-id: ${AWS_ACCESS_KEY_ID}
secret-access-key: ${AWS_SECRET_ACCESS_KEY}
region: eu-west-2
sync-interval: 1s

snapshot:
  interval: 1h
  retention: 72h

dbs:
  - path: /var/www/storage/database/database.sqlite
    replica:
      url: s3://my-app-litestream/app
    # Restore from the replica on boot if the volume came up empty (v0.5.6+)
    restore-if-db-not-exists: true

  - path: /var/www/storage/database/queue.sqlite
    replica:
      url: s3://my-app-litestream/queue

Run it as the parent process of PHP-FPM in a container, so replication shares the application's lifecycle:

COPY --from=litestream/litestream:0.5 /usr/local/bin/litestream /usr/local/bin/litestream
COPY litestream.yml /etc/litestream.yml

CMD ["litestream", "replicate", "-exec", "php-fpm"]

On a VPS without containers, a systemd unit running litestream replicate is the equivalent. Either way, give the IAM credentials PutObject, GetObject and ListBucket on that prefix — and nothing else unless you need Litestream's retention enforcement to delete old files for you.

This is a different tool from a nightly dump. If you want both, the automated database backups to S3 article covers the scheduled-archive strategy; Litestream gives you an RPO measured in seconds, and a nightly dump gives you something a human can read. Running both is reasonable.

Restore from the replica before you need to#

A replication pipeline you have never restored from is a hypothesis. Rehearse it now, on a throwaway path, and write down the timing.

# What would be restored, without writing anything (v0.5.12+)
litestream restore -dry-run -o /tmp/rehearsal.db s3://my-app-litestream/app

# The real thing, with a post-restore integrity check
time litestream restore \
  -integrity-check quick \
  -o /tmp/rehearsal.db \
  s3://my-app-litestream/app

For a few hundred megabytes replicated to a bucket in the same region, expect that to finish in well under a minute. Point-in-time recovery uses -timestamp with an RFC3339 value:

litestream restore -timestamp 2026-11-13T22:15:00Z \
  -o /tmp/before-the-mistake.db \
  s3://my-app-litestream/app

Two things to know about that. Litestream replays whole LTX files, so -timestamp and -txid can only land on file boundaries — run litestream ltx -level all /path/to/db to see which restore points actually exist, and confirm your target with -dry-run before relying on it. And restore refuses to overwrite a non-empty file, which is a safety feature: always restore to a new path, verify it, then swap.

Litestream is not an undo button for application mistakes either. php artisan db:wipe against a replicated database replicates the wipe. Point-in-time restore is your recovery from that, which is another reason to know the timestamp syntax before you need it at 2am.

Deploy a database that is a file#

The .sqlite file must live on a persistent volume, outside the release directory and outside the image. An atomic-deploy layout that symlinks storage/ already gets this right; a Docker build that COPYs the database into the image does not, and will silently reset production on the next deploy.

# compose.yaml
services:
  app:
    image: my-app:latest
    volumes:
      - app-db:/var/www/storage/database
    environment:
      DB_CONNECTION: sqlite
      DB_DATABASE: /var/www/storage/database/database.sqlite

volumes:
  app-db:

Three details that bite.

The WAL brings sidecar files. database.sqlite-wal and database.sqlite-shm live next to the database and must be on the same volume — copying only the .sqlite file from a running system gives you a database missing its most recent transactions. Use sqlite3 ... ".backup" or Litestream, never cp.

Permissions are on the directory, not just the file. SQLite writes the WAL and journal files into the containing directory, so the PHP-FPM user needs write access to the directory even if the database file itself is writable.

Migrations still run on deploy, and the expand/contract rules for zero-downtime migrations apply unchanged. SQLite's limited ALTER TABLE support means Laravel silently takes a table-rebuild path for some column changes — on a large table that is a long write lock, so treat those migrations as maintenance windows.

Monitor WAL size and replication lag#

Four numbers tell you whether this is still working: database file size, WAL file size, Litestream replication lag, and busy-timeout retries.

The WAL is the interesting one. It should checkpoint back into the main database continuously and stay small. A WAL that grows and never shrinks means a long-running read transaction is pinning it — usually a report query or a leaked connection in a worker — and left alone it will eat the volume.

Artisan::command('db:sqlite-health', function () {
    $path = config('database.connections.sqlite.database');
    $wal = file_exists("{$path}-wal") ? filesize("{$path}-wal") : 0;

    $this->table(['metric', 'value'], [
        ['database', number_format(filesize($path) / 1024 / 1024, 1).' MB'],
        ['wal', number_format($wal / 1024 / 1024, 1).' MB'],
        ['journal_mode', DB::select('PRAGMA journal_mode')[0]->journal_mode],
        ['page_count', DB::select('PRAGMA page_count')[0]->page_count],
    ]);
});

Litestream exposes Prometheus metrics for replication lag — scrape those rather than inferring health from the process being alive. If you already run the stack from the Laravel production observability guide, a WAL-size alert and a replication-lag alert are two panels and worth the ten minutes.

Test migrations against SQLite and Postgres in CI#

Run the migration suite against both engines, even if you only deploy one. It catches SQLite-only SQL before it becomes a migration you cannot run, and it keeps the exit plan viable.

# .github/workflows/tests.yml
jobs:
  test:
    strategy:
      matrix:
        database: [sqlite, pgsql]
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - run: composer install --no-interaction --prefer-dist
      - run: php artisan migrate --force
        env:
          DB_CONNECTION: ${{ matrix.database }}

The GitHub Actions matrix testing setup covers the full version matrix if you want to add PHP versions to that grid.

One known wrinkle: migrate:fresh against a database already in WAL mode can fail, because dropping and recreating the file fights the existing WAL and its sidecars. In CI, delete the database file before migrating rather than relying on migrate:fresh.

Know when to move to Postgres#

Write the exit plan while things are calm. Keep migrations engine-agnostic, avoid SQLite-specific SQL in queries, and know roughly where your write ceiling sits — run the stress command above against production-shaped data and find out, rather than guessing.

The triggers are concrete: a second application server, sustained write concurrency that your busy timeout starts absorbing as latency, a need for replicas, or an analytics workload that wants to run long queries against live data. When you hit one, upgrading and migrating Postgres with logical replication is the next stop. Migrating early and calmly costs a weekend; migrating during an incident costs considerably more.

Wrapping Up#

Set journal_mode, busy_timeout and synchronous in config/database.php today, move your queue onto its own file, and run one Litestream restore to a scratch path so you know the command and the timing. That is an afternoon of work, and it is the difference between SQLite being a liability and being the simplest database you have ever operated.

From here, the two things worth reading next are Supervisor configuration for queue workers, because worker count is the main lever on write contention, and deploying Laravel with Kamal 2 on a VPS, which is the deployment shape this configuration suits best.

FAQ#

Can you use SQLite in production with Laravel?

Yes, for a single-server application with modest write volume. The constraints are real — one writer at a time, no safe network access to the file, no replicas — but within them SQLite is fast and dramatically simpler to operate than a database server. What makes it production-grade is WAL mode, a busy timeout, continuous replication and a rehearsed restore, none of which are on by default.

How do I enable WAL mode for SQLite in Laravel?

Set 'journal_mode' => 'WAL' on the sqlite connection in config/database.php. Laravel issues the pragma when it opens the connection, and because WAL mode is written into the database file header it persists for every future connection. You do not need a migration or a manual sqlite3 session to turn it on.

Why do I get 'database is locked' in Laravel with SQLite?

SQLite allows exactly one writer at a time, and by default a second writer that finds the lock held fails immediately rather than waiting. Setting busy_timeout to a few thousand milliseconds makes that writer wait for a lock that usually clears within milliseconds, and WAL mode stops readers from blocking the writer at all. If errors persist after both, the cause is usually too many queue workers or a transaction that reads before it writes.

What is Litestream and how do I use it with Laravel?

Litestream is a daemon that watches a SQLite database's write-ahead log and streams changes to object storage such as S3 continuously. You point a litestream.yml at your database path and a bucket URL, then run litestream replicate -exec php-fpm so replication shares the application's lifecycle. Recovery is litestream restore against the replica URL, optionally with a -timestamp flag for point-in-time recovery.

Can SQLite handle Laravel queue workers?

It can, with two conditions. Put the queue tables in their own SQLite file rather than the application database, so worker writes do not contend with request writes, and keep the worker count low — two or three, not a dozen. Beyond that, a Redis or Valkey queue connection is the better answer, because queue traffic is exactly the sustained-write pattern SQLite is worst at.

When should I move from SQLite to Postgres?

Move when you need a second application server, when sustained write concurrency shows up as latency your busy timeout is absorbing, when you need read replicas, or when long analytical queries start blocking the writer. None of those arrive gradually — they tend to come with a growth milestone — so decide the threshold in advance and migrate before it becomes an incident.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.