Rails 8 SQLite in productie: WAL-modus, Litestream-backups en wanneer je SQLite boven Postgres kiest
Rails 8 SQLite productie: WAL-modus pragmas, Litestream continue backups, Kamal deployment met volumes, en wanneer SQLite beter is dan Postgres.
Afgelopen winter belde een founder me, uitgeput. Zijn Rails 7-app verstookte twaalfhonderd dollar per maand aan een managed Postgres die hij amper gebruikte. Iets van tachtig betalende klanten, op piek misschien vierduizend requests per uur, een database die comfortabel in RAM paste. Hij had te horen gekregen dat je Postgres nodig hebt in productie. Hij had te horen gekregen dat SQLite speelgoed is. Na negentien jaar Rails verdienen beide uitspraken in 2026 een serieuze tegenwerping.
Rails 8 leverde SQLite gepromoveerd tot eersteklas productiedatabase, niet meer een gemak voor development. Solid Queue, Solid Cache en Solid Cable draaien allemaal op SQLite. DHH zet er zelf productie-apps mee in de lucht. En het verhaal van Rails 8 SQLite production is oprecht goed — als je WAL-modus, pragmas, backups en de exacte faalmodi begrijpt waar SQLite stopt het juiste antwoord te zijn. Deze post is de playbook die ik klanten geef die het zat zijn de Postgres-belasting te betalen voor workloads die het niet nodig hebben.
Waarom Rails 8 SQLite production opeens plausibel is
Het grootste deel van de Rails-geschiedenis was SQLite in productie een meme. De database vergrendelde bij elke schrijfactie. Connection handling was houterig. Backups waren een tarball en een schietgebedje. Het advies “gebruik Postgres” klopte, zelfs voor piepkleine apps, omdat het operationele verhaal makkelijker was.
Drie dingen veranderden.
Ten eerste werd SQLite zelf serieus. WAL-modus (Write-Ahead Logging) betekent dat lezers schrijvers niet meer blokkeren en schrijvers lezers niet meer. De combinatie journal_mode = WAL en synchronous = NORMAL is duurzaam genoeg voor vrijwel elke webapp en dramatisch sneller dan het oude rollback journal. Moderne SSD’s en NVMe-drives zorgen dat één SQLite-bestand duizenden schrijfacties per seconde aankan.
Ten tweede voegde Rails 8 expliciete productieondersteuning toe. De sqlite3-adapter zet nu zinnige defaults voor productie (busy_timeout, WAL, foreign keys), de Rails 8-generators maken aparte SQLite-databases voor queue, cache en cable, en het team is eerlijk geweest over waar SQLite wel en niet goed in is.
Ten derde losten Litestream en litefs het backupprobleem op. Litestream streamt je WAL continu naar S3. Point-in-time restore is een oneliner. Je krijgt een duurzame, gerepliceerde backup voor twee dollar per maand aan S3-opslag. Het bezwaar “maar de backups dan” is verdwenen.
Het resultaat: een Rails-app op één server met SQLite, Litestream, Solid Queue en Solid Cache kost tien dollar per maand en presteert beter dan de meeste managed Postgres-opstellingen voor read-heavy workloads. Dat is wat we hier bouwen.
De productie-database.yml
De Rails 8-default config/database.yml voor SQLite zit al dicht bij wat je wilt. Hier is de versie die ik deploy na een paar tweaks voor Rails 8 SQLite production-veiligheid.
# config/database.yml
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
pragmas:
journal_mode: WAL
synchronous: NORMAL
foreign_keys: ON
busy_timeout: 5000
cache_size: -64000 # 64 MB page cache
temp_store: MEMORY
mmap_size: 134217728 # 128 MB mmap
wal_autocheckpoint: 1000
production:
primary:
<<: *default
database: storage/production.sqlite3
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
Een paar van deze pragmas zijn belangrijk genoeg om toe te lichten.
journal_mode: WAL is de hoofdwijziging. Zonder dit heb je één globale schrijflock en blokkeert je app onder elke vorm van concurrency. Zet dit altijd.
synchronous: NORMAL ruilt een minuscuul venster aan duurzaamheid (de laatste transactie kan verloren gaan bij stroomuitval, maar de database blijft consistent) in voor een grote prestatiewinst. FULL is de SQLite-default en is overkill voor vrijwel alle webworkloads. Cloud-VM’s verliezen geen stroom.
busy_timeout: 5000 vertelt SQLite om tot vijf seconden te wachten op een schrijflock in plaats van direct SQLite3::BusyException te raisen. Gecombineerd met WAL-modus, die gelijktijdig lezen toestaat, is dit de meest effectieve bescherming tegen database is locked-fouten in productie.
cache_size: -64000 is de page cache in kilobytes (negatief getal = KB, positief = pages). 64 MB is genereus en reduceert disk reads op een hot dataset dramatisch.
mmap_size: 134217728 schakelt memory-mapped I/O in voor de eerste 128 MB van de database. Reads uit het mmap-gebied slaan de read syscall volledig over. Op een database die in RAM past, verandert dit SQLite vrijwel in een in-process cache.
Aparte databasebestanden voor queue, cache en cable zijn belangrijk omdat ze heel verschillende schrijfpatronen hebben, en je wilt niet dat Solid Queue’s zware schrijfverkeer concurreert met je primaire database om de schrijflock. Rails 8 maakt dit triviaal.
Connection handling en threading
Hier zie ik de meeste productiefouten. SQLite heeft één schrijver tegelijk. Punt. WAL-modus laat veel lezers gelijktijdig met één schrijver, maar twee schrijvers serialiseren nog steeds. Het threading-model van je applicatieserver moet dit respecteren.
Zet voor Puma RAILS_MAX_THREADS=5 per worker en draai meerdere workers. Elke thread krijgt een eigen connection uit de pool. Schrijfacties serialiseren vanzelf omdat de database dat voor je doet. Met busy_timeout: 5000 wacht een schrijfactie die contention raakt tot vijf seconden in plaats van te falen. Dat is bijna altijd wat je wilt.
Draai geen veertig threads per worker. Het serial-writer-model van SQLite betekent dat hoge thread counts op één worker alleen contention creëren zonder dat je er iets voor terugkrijgt. Vijf threads per worker, drie tot vijf workers, is de sweet spot op een 2-4 vCPU-machine. Voor diepere dekking van Puma sizing, zie mijn post over Puma tuning voor workers en threads.
Eén Rails 8-specifieke noot: de sqlite3-adapter zet transaction_mode = IMMEDIATE als default in productie. Dit vermijdt een subtiele deadlock waar twee transacties beide upgraden van read naar write en er een gekilld wordt. Als je upgrade vanaf Rails 7, controleer dat dit aanstaat.
Litestream: continue backups naar S3
Litestream is het stuk dat Rails 8 SQLite production veilig genoeg maakt om er een bedrijf op te bouwen. Het tailt je SQLite WAL en streamt die in real time naar S3. Als je server omvalt, herstel je tot binnen één seconde van het falen. Het hele ding is één binary en één configbestand.
Installeer Litestream naast je Rails-app (een Dockerfile-fragment):
# Dockerfile
FROM ruby:3.4-slim AS base
RUN apt-get update && apt-get install -y \
sqlite3 curl ca-certificates && \
curl -L https://github.com/benbjohnson/litestream/releases/download/v0.3.13/litestream-v0.3.13-linux-amd64.deb -o /tmp/litestream.deb && \
dpkg -i /tmp/litestream.deb && \
rm /tmp/litestream.deb && \
rm -rf /var/lib/apt/lists/*
Dan de Litestream-config:
# config/litestream.yml
dbs:
- path: /rails/storage/production.sqlite3
replicas:
- type: s3
bucket: $LITESTREAM_BUCKET
path: production/primary
region: $LITESTREAM_REGION
access-key-id: $LITESTREAM_ACCESS_KEY_ID
secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
retention: 168h
snapshot-interval: 24h
- path: /rails/storage/production_queue.sqlite3
replicas:
- type: s3
bucket: $LITESTREAM_BUCKET
path: production/queue
region: $LITESTREAM_REGION
access-key-id: $LITESTREAM_ACCESS_KEY_ID
secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
retention: 72h
Draai Litestream als sidecar-proces. Het patroon dat ik gebruik is litestream replicate -exec "bin/rails server", waardoor Litestream het Rails-proces supervisort. Als Rails sterft, sterft Litestream. Als Litestream sterft, herstart je container. Het Rails-team publiceerde ook een litestream-ruby-gem die dit verpakt met zinvolle defaults en een Rails-vriendelijke CLI.
# Gemfile
gem "litestream"
# Draai Rails onder Litestream-supervisie
bundle exec litestream replicate
Restoren is één commando:
litestream restore -o storage/production.sqlite3 \
s3://$LITESTREAM_BUCKET/production/primary
Ik doe per kwartaal een fire drill op elke productie-SQLite-app die ik beheer. Spin een verse VM op, restore vanaf S3, wijs een staging Rails ernaartoe, draai de test suite ertegen. Het hele ding duurt tien minuten. De eerste keer dat je het doet, slaap je beter. Ongetestte backups zijn geen backups.
Rails 8 SQLite production deployen met Kamal
Kamal 2 deployt SQLite Rails-apps prachtig zodra je persistent volumes begrijpt. Het databasebestand moet container-restarts en image-swaps overleven, dus het kan niet binnen de container leven.
# config/deploy.yml
service: myapp
image: myapp
servers:
web:
hosts:
- 192.0.2.10
options:
add-host: host.docker.internal:host-gateway
registry:
server: 127.0.0.1:5555
username: rogerheykoop
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
RAILS_MAX_THREADS: 5
WEB_CONCURRENCY: 3
secret:
- RAILS_MASTER_KEY
- LITESTREAM_ACCESS_KEY_ID
- LITESTREAM_SECRET_ACCESS_KEY
- LITESTREAM_BUCKET
- LITESTREAM_REGION
volumes:
- /var/lib/myapp/storage:/rails/storage
accessories:
litestream:
image: litestream/litestream:0.3.13
host: 192.0.2.10
volumes:
- /var/lib/myapp/storage:/rails/storage
files:
- config/litestream.yml:/etc/litestream.yml
cmd: replicate -config /etc/litestream.yml
env:
secret:
- LITESTREAM_ACCESS_KEY_ID
- LITESTREAM_SECRET_ACCESS_KEY
- LITESTREAM_BUCKET
- LITESTREAM_REGION
Het volumes-blok is de sleutel. Het host-pad /var/lib/myapp/storage is waar je SQLite-bestanden daadwerkelijk leven. Het container-pad /rails/storage is wat de Rails-app ziet. Container-rebuilds, image-swaps en Kamal-redeploys behouden allemaal de data. Voor een diepere walkthrough van Kamal 2-productiedeploys, zie mijn post over Rails 8 deployen met Kamal 2.
Eén ding dat Kamal niet automatisch afhandelt: zorgen dat slechts één Rails-container tegelijk naar een bepaald SQLite-bestand schrijft. Als je twee webcontainers op dezelfde host draait die naar hetzelfde volume wijzen, krijg je database is locked-fouten en erger, potentiële corruptie. Voor single-host SQLite draai je één set Puma-workers per databasebestand. Schaal door workers en threads binnen dat proces toe te voegen, niet door containers toe te voegen.
Wanneer SQLite Postgres verslaat voor Rails-apps
Dit is het stuk waar mensen het mis hebben. Ik zeg niet dat SQLite altijd het juiste antwoord is. Ik zeg dat het vaker het juiste antwoord is dan de Rails-community momenteel toegeeft.
SQLite past goed wanneer:
- Je één applicatieserver hebt. SQLite is een bestand. Bestanden leven op één machine. Op het moment dat je een tweede app-server nodig hebt die dezelfde database leest, heb je een netwerk-filesystem nodig (doe het niet) of moet je overschakelen op Postgres. De meeste apps komen nooit toe aan een tweede app-server.
- Je dataset in RAM past, of dichtbij. Met een 64 MB page cache en mmap is SQLite verschrikkelijk snel op datasets tot een paar gigabyte. Voorbij 50 GB doe je meer disk I/O en verandert het plaatje.
- Je veel meer leest dan schrijft. WAL-modus laat lezers vliegen. Een SQLite-backed Rails-app doet comfortabel tienduizend reads per seconde per core. Schrijfacties zijn serieel en plafonneren rond een paar duizend per seconde per databasebestand.
- Je een paar honderd milliseconden replicatie-lag voor backups kunt tolereren. Litestream is asynchroon. In het slechtste geval verlies je één transactie. Voor de meeste B2B SaaS, blogs, interne tools en dashboards is dat prima. Voor betalingsverwerking of iets met strikte regelgevende duurzaamheidseisen niet.
- Je wilt optimaliseren voor operationele eenvoud boven horizontale schaal. Eén server. Eén backuptarget. Geen connection pooler, geen replica lag, geen
pg_hba.conf. De hele stack past in je hoofd.
SQLite past slecht wanneer:
- Je meerdere app-servers nodig hebt die gelijktijdig schrijven. Gebruik Postgres.
- Je zware schrijfcontention hebt. Een queue met duizenden writes per seconde op hetzelfde databasebestand raakt het schrijflock-plafond.
- Je echte point-in-time recovery met nul dataverlies nodig hebt. Gebruik Postgres met synchrone replicatie.
- Je rijke indextypes nodig hebt zoals GIN, BRIN, partial indexes met complexe predicates, of trigram search. SQLite is verbeterd maar Postgres ligt nog voor. Voor full-text search op schaal, zie mijn post over Postgres full-text search met pg_search.
- Je team Postgres al goed beheert. Tools wisselen om vijftig dollar te besparen is zelden de cognitieve kosten waard.
Voor de founder die ik aan het begin noemde, verhuisden we zijn app naar één Hetzner-box met SQLite, Solid Queue en Litestream. Zijn maandelijkse hostingrekening ging van twaalfhonderd naar achtenveertig dollar. P95-responstijd verbeterde omdat round trips naar de database nu in-process zijn. Hij heeft meer runway en een stack die hij zelf kan debuggen.
SQLite in productie monitoren
Het monitoringverhaal is simpeler dan Postgres omdat er minder bewegende delen zijn, maar het is niet nul. Drie dingen om in de gaten te houden.
Ten eerste, groei van de databasebestandsgrootte. Een onverwachte groeispike betekent meestal een ontbrekende index of een vergeten delete_all ergens. Draai du -h storage/*.sqlite3 dagelijks.
Ten tweede, WAL-bestandsgrootte. De WAL groeit tijdens writes en krimpt bij checkpoint. Als de WAL consistent groter is dan de hoofdatabase, heb je een vastgelopen checkpoint, vaak omdat een langlopende read-transactie het tegenhoudt.
# config/initializers/sqlite_stats.rb
class SqliteStats
def self.report
db = ActiveRecord::Base.connection
{
page_count: db.execute("PRAGMA page_count").first["page_count"],
page_size: db.execute("PRAGMA page_size").first["page_size"],
wal_pages: db.execute("PRAGMA wal_checkpoint(PASSIVE)").first
}
end
end
Ten derde, Litestream-lag. Het commando litestream snapshots vertelt je wanneer de laatste snapshot voltooide. Page op snapshots ouder dan vijftien minuten.
Voor diepere observability-patronen in Rails-apps, zie mijn post over OpenTelemetry Rails 8 productie-observability.
FAQ
Is SQLite echt veilig voor Rails 8 productie-apps?
Ja, voor het workloadprofiel hierboven beschreven. Single-server apps met read-heavy verkeer en een dataset die comfortabel op schijf past, draaien prachtig op SQLite met WAL-modus, zinnige pragmas en Litestream-backups. Rails 8 levert expliciete productieondersteuning, en het Rails core-team gebruikt het zelf in productie. De reputatie “SQLite is speelgoed” is van tien jaar geleden.
Hoe verhoudt SQLite zich tot Postgres qua Rails 8-performance?
Voor reads op een hot dataset is SQLite vaak sneller omdat de data in-process zit en er geen netwerkhop is. Voor writes schaalt Postgres beter voorbij een paar duizend writes per seconde omdat SQLite schrijvers serialiseert. Voor analytische queries met complexe joins heeft Postgres een volwassener query planner. Het eerlijke antwoord is dat voor de meeste CRUD Rails-apps onder een paar honderd requests per seconde, beide ver voorbij presteren wat de app nodig heeft.
Kan ik Litestream en Solid Queue samen op dezelfde Rails-app gebruiken?
Ja, en je moet ze op aparte SQLite-databasebestanden zetten. Solid Queue doet zware writes en je wilt die contention niet op je primaire database. De Rails 8-generators zetten aparte queue-, cache- en cable-databases out of the box op. Wijs Litestream naar de primaire database en eventueel naar queue als je duurzame job-historie nodig hebt.
Wat gebeurt er als mijn SQLite-bestand corrupt raakt?
Je restorest vanaf Litestream. Het hele punt van WAL naar S3 streamen is dat corruptie op de bron herstelbaar is. Draai litestream restore naar een nieuw bestand, verifieer met PRAGMA integrity_check, en swap. Als je geen recovery fire drill hebt gedaan, heb je geen backups. Draai er per kwartaal eentje.
Aan het nadenken of SQLite of Postgres past bij je Rails-app, en hoe de operationele kosten er echt uitzien? TTB Software helpt Rails-teams deze keuzes maken zonder dogma. Negentien jaar Rails, en de juiste database is welke je app daadwerkelijk nodig heeft.
Related Articles
Rails Strong Migrations: Vang onveilige databasewijzigingen voordat ze productie lockken
Rails Strong Migrations: vang onveilige Postgres-wijzigingen — NOT NULL toevoegen, hernoemen, indexen zonder CONCURRE...
Rails pg_stat_statements: Vind Trage Queries in Productie Voordat Je Gebruikers Het Doen
Rails pg_stat_statements opzetten, queryen en analyseren: vind de trage queries die productie écht raken, normaliseer...
Rails State Machine: AASM-patronen voor orders, abonnementen en workflows in productie
Rails state machine met AASM: productiepatronen voor orders, abonnementen en workflows. Guards, callbacks, optimistic...