Skip to content
Go back

SQLite Replication: Litestream and rqlite

By SumGuy 14 min read
SQLite Replication: Litestream and rqlite
Contents

Your SQLite Problem Just Got More Interesting

You’ve been running SQLite locally, it works great, and then someone asks: “But what if the server dies?” You smile, nod, and realize you need to think about this differently.

SQLite is fantastic for single-server setups. It’s fast, zero configuration, no daemons, and fits in a pocket watch. But it’s not built for clustering. You can’t just point three instances at the same file and expect consensus.

Two tools take opposite approaches. Litestream treats SQLite as a fire-and-forget application, continuously shipping changes to cloud storage. rqlite wraps SQLite with Raft consensus, turning it into a real distributed database. Both solve the durability problem. Neither tries to be Postgres.

The choice isn’t “which is better”; it’s “which fits your failure tolerance and operational complexity budget?”

Versions in this article, checked September 2026: Litestream v0.5.17, rqlite v10.3.0. Both had breaking changes recently, and the older guides you’ll find via search will hand you config keys and flags that no longer do anything.

Litestream: Continuous Backup as Your Replication Strategy

Litestream runs alongside your SQLite instance and ships changes to cloud storage: S3, Backblaze B2, or any S3-compatible target. It’s a live backup that never stops running.

How it works:

Your app writes to app.db. SQLite creates a WAL file (app.db-wal) that holds committed pages waiting to be checkpointed. Litestream watches that WAL and pushes the changes to your bucket every few seconds (configurable). When disaster strikes, you restore from the bucket and you’re back close to where you left off.

The 0.3.x line uploaded raw WAL segments. The 0.5.x line packages changes into LTX files and runs multi-level compaction in the background. Start litestream replicate and you’ll see compaction monitors for levels 1, 2, 3 and 9 plus an L0 retention monitor in the logs. That matters for two reasons: the storage layout on your bucket is different, and 0.5.x cannot restore a backup written by 0.3.x. Plan your upgrade as a fresh replication target, not an in-place swap.

Setup example:

litestream.yml
# snapshot and l0-retention are TOP-LEVEL keys in 0.5.x, not replica keys.
snapshot:
interval: 24h
retention: 720h # keep 30 days of snapshots
l0-retention: 24h # default is 5m
dbs:
- path: /data/app.db
replica:
url: s3://my-bucket/db-backups/app.db
access-key-id: ${LITESTREAM_AWS_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_AWS_SECRET_ACCESS_KEY}
region: us-east-1
sync-interval: 5s

Two traps in that file, both of which every pre-0.5 tutorial will walk you into.

First, it’s replica: (singular), not replicas: (a list). The plural form is deprecated. Upstream dropped multi-replica support on purpose, so there is a single remote data authority.

Second, and worse: retention: 720h nested under the replica is not a valid key and Litestream will not tell you. The config is parsed with a non-strict YAML unmarshal, so unknown keys are silently dropped. Put retention: 720h under the replica and start the daemon, and the L0 retention monitor logs retention=5m0s, the default. Move it to the top level as l0-retention: 24h and the same log line reads retention=24h0m0s. If you have been trusting a nested retention key to hold 30 days of history, go check your logs right now.

Drop the config in place, set your credentials, and run:

Terminal window
litestream replicate -config /etc/litestream.yml

litestream replicate runs a server in the foreground. It does not daemonize itself, so put it behind a systemd unit or run it as a sidecar container. It shuts down cleanly on SIGTERM. Your app doesn’t know it’s happening. No connection pooling, no consensus overhead, no Raft elections.

The restore drill:

Your server catches fire. You spin up a new one and pull the latest backup:

Terminal window
litestream restore -o /data/app.db s3://my-bucket/db-backups/app.db

Litestream fetches the most recent snapshot and replays the changes recorded after it. Be honest with yourself about the timing: the snapshot is a full copy of the database, so restoring a multi-gigabyte database means downloading multiple gigabytes. Your restore time is bounded by your bucket’s egress throughput, not by cleverness. Test it before you need it and write the real number down in your runbook.

Real trade-off: Litestream only helps up to your last completed sync. If the VM dies mid-write and the WAL goes with it, you lose whatever hadn’t shipped. With sync-interval: 5s, that’s at most 5 seconds of committed work. For most self-hosted projects, that’s acceptable. For financial transactions or medical records, you’d want synchronous durability, which Litestream doesn’t offer.

Also: you’re not getting read scaling. Backups live in the bucket. If you need to read from a replica, you need a separate replica database (which Litestream can restore to, but you manage the failover).

rqlite: Raft Consensus with SQLite as the Engine

rqlite is a distributed SQL database built on SQLite. It clusters nodes using Raft consensus, the same algorithm behind etcd and Consul (and therefore, indirectly, the Kubernetes control plane, which stores its state in etcd). Reads can be served locally or through the leader, depending on the consistency level you ask for.

How it works:

You run three (or five, or seven) rqlite nodes. They elect a leader. When you write to the leader, it appends the entry to its Raft log, replicates it to the followers, waits for a quorum to acknowledge, and then applies it to SQLite. Followers apply the same entries in the same order, so every node converges on the same database.

Cluster setup example:

Node 1 (leader):

Terminal window
rqlited -http-addr=0.0.0.0:4001 \
-raft-addr=0.0.0.0:4002 \
-node-id=node1 \
/data/node1

Node 2 (follower):

Terminal window
rqlited -http-addr=0.0.0.0:4001 \
-raft-addr=0.0.0.0:4002 \
-node-id=node2 \
-join=node1:4002 \
/data/node2

Node 3 is the same with -node-id=node3.

Look closely at -join. It takes a host:port pair pointing at the Raft port, not a URL and not the HTTP port. Get it wrong and the node dies at startup rather than degrading quietly:

Terminal window
# -join=http://node1:4001
fatal: http://node1:4001 is an invalid join address
# -join=node1:4001
clustering failure: join address node1:4001 appears to be serving HTTP
when it should be Raft

The second message is the one that eats an afternoon, because 4001 is the port you type into everything else.

Now you have a three-node cluster. Writes go to the leader. Raft handles failure detection and leader re-election. If the leader dies, a new one is elected in seconds. Your app can point at any node; followers redirect writes to the leader.

Writing data:

The request body is a bare JSON array of statements. Wrapping it in {"statements": [...]}, which older examples do, gets you a flat invalid request:

Terminal window
curl -X POST http://localhost:4001/db/execute \
-d '["CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"]'
# {"results":[{}]}

Parameterized statements are a nested array, which also saves you from quoting hell in the shell:

Terminal window
curl -X POST http://localhost:4001/db/execute \
-d '[["INSERT INTO users (name) VALUES (?)", "Alice"]]'
# {"results":[{"last_insert_id":1,"rows_affected":1}]}

Reading data:

Let curl do the URL encoding. A raw ?q=SELECT * FROM users on the command line will glob against your working directory before curl ever sees it:

Terminal window
curl -G http://localhost:4001/db/query \
--data-urlencode 'q=SELECT * FROM users'

Read consistency is a choice, and the default is not “local”:

By default, rqlite routes reads so that you get linearizable results, which means talking to the leader. Add level=none and the node answers from its own SQLite copy without checking whether it’s still part of a healthy cluster. That’s the fast path, and it’s also the stale path.

The difference is easy to see. Stop two of three nodes so quorum is gone, then query the survivor. A default-level read returns leader not found, and so does a write. The same query with level=none returns data immediately, cheerfully, and possibly out of date. Choose per query:

Terminal window
curl -G 'http://localhost:4001/db/query?level=none' \
--data-urlencode 'q=SELECT * FROM users'

The restore drill:

A node dies. The remaining two keep quorum and keep serving. What does not happen is automatic eviction: query /nodes after killing a node and it’s still listed as voter=True, reachable=False. It stays a voting member of the cluster until you remove it with /remove or bring it back. That’s the correct behavior for Raft (a temporarily unreachable node is not the same as a departed one), but it means a “self-healing” cluster still needs you to clean up after a permanent loss. Bring the node back with the same node ID and data directory and it catches up via snapshot plus log replay, no manual restore script needed.

Real trade-off: Raft adds consensus overhead. Writes are slower than single-instance SQLite because you’re waiting for a quorum round-trip, so your write latency has a floor set by the network between your nodes. On a LAN that’s sub-millisecond overhead; across regions it’s tens of milliseconds. Measure it on your own hardware rather than trusting a number from a blog post. Also, rqlite has fewer years running in production than Postgres, and a smaller community, so you’re debugging with fewer StackOverflow answers.

The Decision Tree

Use Litestream if:

Use rqlite if:

Use both if:

Real-World Example: Self-Hosted Wiki

Say you’re running Wiki.js with SQLite as the backend. It’s a supported option (SQLite 3.9 or later, set db.type: sqlite and a storage: path), and today it lives on one server. You want high availability without running Postgres.

Option A: Litestream

Add litestream.yml, point it at a Backblaze B2 bucket, and run Litestream as a service alongside Wiki.js. If the server dies, you restore from B2 to a new VM and restart Wiki.js. Total RTO: minutes, dominated by VM provisioning and the restore download. RPO: 5 seconds.

Cost, at Backblaze’s published rates as of September 2026: pay-as-you-go B2 storage is billed per byte-hour at $6.95 per TB per 30 days, which works out to roughly $0.007/GB/month. Egress is free up to 3x your average monthly stored data and $0.01/GB beyond that, and free without limit if you pull through one of their partner CDNs (Cloudflare, Fastly and bunny.net among them). A wiki database measured in hundreds of megabytes costs you rounding-error money. Check the current rate before you quote it to anyone; it has moved.

Option B: rqlite

Run three rqlite nodes across three cheap VPSes. Point Wiki.js at rqlite… except you can’t. Wiki.js speaks the SQLite file API, and rqlite speaks HTTP. Swapping in rqlite means the application has to be written against rqlite’s API or one of its client libraries. This is the single biggest constraint on rqlite in a self-hosting context: it works for apps you write, not for off-the-shelf apps that expect a local database file.

So for the wiki specifically, Option A is the only one of the two that actually drops in. Option B is what you reach for when the application is yours and you want the cluster to handle failover instead of you.

The Catch: Schema Changes and Snapshots

Both tools struggle with schema migrations in one way:

Litestream: Your app handles schema changes (ALTER TABLE and friends). Litestream replicates database pages, not intent. If you’ve got a rolling deploy with old and new versions running, make sure your schema changes are backward compatible. Litestream doesn’t care, it just ships bytes.

rqlite: Same story. Schema changes run on the leader and replicate via Raft to followers. All nodes converge on the same schema. But if you’re upgrading your app in a rolling manner, coordinate carefully.

Neither tool is smarter than your app logic here. Don’t expect replication to save you from a bad migration.

Operational Simplicity vs. Resilience

The summary I keep coming back to: Litestream is operational simplicity in a tarball. rqlite is resilience with a price tag.

Litestream is a single binary that watches your database and uploads. It fails loudly, restarts easily, and doesn’t require you to think about consensus. Your single-node application stays single-node. Everything you know about SQLite still works. You trade some durability guarantees for simplicity.

rqlite flips the trade: you get automatic failover, local reads, and true distributed durability. But you’re now running a cluster, you need monitoring and health checks, and your application has to talk HTTP instead of opening a file.

For a homelab project you’re tinkering with on weekends, Litestream wins. For a service you wrote yourself where uptime matters and you’ve got the infrastructure to run multiple nodes anyway, rqlite is worth considering.

Getting Started

Litestream:

Grab the release asset by its real name. The Linux tarballs use x86_64, not amd64, and only the separate VFS build uses linux-amd64:

Terminal window
curl -L -o litestream.tar.gz \
https://github.com/benbjohnson/litestream/releases/download/v0.5.17/litestream-0.5.17-linux-x86_64.tar.gz
tar xzf litestream.tar.gz
sudo mv litestream /usr/local/bin/
cat > /etc/litestream.yml <<'EOF'
dbs:
- path: /data/app.db
replica:
url: s3://bucket/app.db
access-key-id: YOUR_KEY
secret-access-key: YOUR_SECRET
EOF
litestream replicate -config /etc/litestream.yml

rqlite:

The upstream install script is the low-effort path:

Terminal window
curl -fsSL https://rqlite.io/install.sh -o install.sh
sh install.sh

By hand, note three things: curl needs -L to follow GitHub’s redirect to the asset, the tarball extracts into a versioned directory, and the daemon binary is rqlited (plain rqlite is the interactive shell):

Terminal window
curl -L -o rqlite.tar.gz \
https://github.com/rqlite/rqlite/releases/download/v10.3.0/rqlite-v10.3.0-linux-amd64.tar.gz
tar xzf rqlite.tar.gz
sudo mv rqlite-v10.3.0-linux-amd64/rqlite* /usr/local/bin/
rqlited -node-id=node1 /data/node1

If you’re coming from v8 or v9, two flags moved in v10: -on-disk-path is gone, and -raft-timeout is now -raft-heartbeat-timeout. The HTTP API and clustering protocol are unchanged, but a v10 node cannot join a v9 cluster, so upgrade the whole cluster together.

Common Questions

Does Litestream give me a read replica?

No. Litestream ships changes to object storage, and object storage cannot answer SQL queries. To read from a second copy you restore the database to another host and manage staleness and failover yourself. If you want live queryable replicas, rqlite or Postgres streaming replication is the tool, not Litestream.

Can I still use replicas: with multiple destinations in Litestream 0.5?

No. The plural replicas: list is deprecated in favor of a single replica: block, because upstream wanted one authoritative remote copy. Unknown keys are silently ignored rather than rejected, so a stale config may look accepted while doing nothing. Check your startup logs after any config change.

Why does my rqlite node refuse to join the cluster?

Because -join wants a Raft address, not an HTTP URL. -join=http://node1:4001 dies with is an invalid join address, and -join=node1:4001 dies with appears to be serving HTTP when it should be Raft. Use the Raft port: -join=node1:4002. Both failures are fatal at startup, not silent.

Does rqlite keep serving reads if it loses quorum?

Only at level=none. With quorum lost, a default-level read on a surviving node returns leader not found, and so does any write. Adding level=none makes that node answer from its local SQLite copy, which is fast and possibly stale. Pick the level per query, deliberately.

Can I put rqlite behind an app that expects a SQLite file?

No. rqlite exposes an HTTP API, not a SQLite file, so applications that open a local database (Wiki.js, most self-hosted PHP and Node apps) cannot point at it. rqlite fits code you control and can write against its API. For off-the-shelf apps, use Litestream for durability instead.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
InfluxDB vs TimescaleDB vs VictoriaMetrics
Next Post
PostGIS for Self-Hosted Mapping

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts