Litestream to Cloudflare R2: Disaster Recovery for SQLite
Every PocketBase database I run streams its SQLite file to R2, so a dead free VM costs me minutes, not data.

SQLite on a single free VM is one disk failure away from gone. No replica, no managed snapshot, no friendly cloud dashboard offering point-in-time restore. Just a .db file on a block volume that Oracle hands you for free and is under no contractual obligation to keep alive.
I run more than a dozen PocketBase backends on one Always Free Oracle ARM box. Two of them have real users. For months I had decent uptime and zero offsite copy of those databases, which is a polite way of saying I had no disaster recovery at all. A system audit flagged it as the single existential gap in the whole setup: live production data, real users, nothing replicated off the instance.
The fix is continuous replication. Litestream watches each SQLite database and ships every change to Cloudflare R2 as it happens. The VM stops being something I have to protect and becomes something I can lose. Kill the box, provision a fresh one, restore from R2, and I am out maybe a few minutes of writes. The whole point: a free VM should be a box you can throw away without flinching.
Why R2 specifically
Litestream replicates to any S3-compatible object store. I picked Cloudflare R2 for one reason that matters more than the rest: no egress fees.
Backup tools have an asymmetry. You write small and often, you read big and rarely, and the reads happen at the worst possible time: a box is already dead and you are restoring a full database under pressure. On most object stores that restore download is billed egress. On R2, egress is free. You pay for storage and operations, not for pulling your own data back in a crisis. For a setup where the entire compute bill is zero, a backup target that also stays near zero is the honest match.
The rest of the fit is ordinary and that is good:
- R2 speaks the S3 API, so Litestream treats it as a generic S3 endpoint with no special casing.
- R2 has a free tier with a standing storage allowance and a monthly floor of free Class A and Class B operations, which comfortably covers a handful of small SQLite databases.
- It sits in the same Cloudflare account I already use for DNS and Pages, so there is one less vendor in the stack.
No egress is the load-bearing reason. Everything else is convenience.
Litestream config for PocketBase
PocketBase stores its data as a SQLite database inside the data directory. Point Litestream at that file and at an R2 bucket, and it handles the rest.
R2 gives you an S3-compatible endpoint shaped like https://<accountid>.r2.cloudflarestorage.com. You generate an R2 API token scoped to that bucket, which yields an access key id and a secret access key. One thing worth knowing up front: a general Cloudflare API token cannot do R2 S3 operations. You mint a dedicated R2 token from the R2 section of the dashboard for this, separate from whatever token runs your DNS or Pages deploys.
Here is the litestream.yml I run, redacted:
# /etc/litestream.yml
access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
dbs:
- path: /pb/pb_data/data.db
replicas:
- type: s3
endpoint: https://<accountid>.r2.cloudflarestorage.com
bucket: my-pb-backups
path: taxwalaai/data.db
region: auto
force-path-style: true
# how often a full snapshot is taken; WAL frames stream continuously
snapshot-interval: 12h
# how long old generations are kept for point-in-time restore
retention: 72h
Credentials come from the environment, never the YAML in a repo, and the secrets live outside any synced directory. R2 wants region: auto and path-style addressing. Give each database its own path prefix inside the bucket so one R2 bucket holds every database in the fleet without collisions, one prefix per project. To replicate several PocketBase instances on the same box, list each as another entry under dbs with its own path and prefix. Litestream replicates them in parallel from a single process.
PocketBase runs SQLite in WAL mode, which is exactly what Litestream needs. Litestream replicates by reading the write-ahead log, so WAL is a hard requirement, not a preference. PocketBase already satisfies it, so there is nothing to change.
Running it as a service
Replication is only real if it survives a reboot. A backup tool you have to remember to start by hand is not a backup tool. Run Litestream as a systemd service so it comes back automatically.
# /etc/systemd/system/litestream.service
[Unit]
Description=Litestream SQLite replication
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/litestream.env
ExecStart=/usr/bin/litestream replicate -config /etc/litestream.yml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
The env file holds the R2 keys:
# /etc/litestream.env (chmod 600, root-owned)
LITESTREAM_ACCESS_KEY_ID=...
LITESTREAM_SECRET_ACCESS_KEY=...
Enable and start it:
sudo chmod 600 /etc/litestream.env
sudo systemctl daemon-reload
sudo systemctl enable --now litestream
sudo systemctl status litestream
journalctl -u litestream -f
The replicate command is long-running. It does the initial snapshot, then streams WAL frames to R2 continuously. Restart=always means a crash or an OOM kill brings it straight back. The log line you want to see is the periodic confirmation that it wrote a snapshot and is shipping WAL segments.
If PocketBase itself runs inside a container, run Litestream as a sidecar with the same config and the database path mounted in. Systemd is simpler when PocketBase runs on bare metal, which is how I run it. Either way the rule holds: it must start on its own after a reboot.
The restore drill
Restoring is the part people skip, and it is the only part that actually matters. A backup you have never restored is a hope, not a backup.
The restore is one command. Litestream pulls the latest snapshot plus the WAL frames since it and rebuilds the database file:
litestream restore \
-config /etc/litestream.yml \
-o /pb/pb_data/data.db \
/pb/pb_data/data.db
Or point it straight at the replica without a config, useful on a brand-new box before anything else is set up:
litestream restore \
-o ./data.db \
s3://my-pb-backups/taxwalaai/data.db
The full recovery flow when the VM is gone:
- Provision a fresh ARM box and install PocketBase and Litestream.
- Drop the same
litestream.ymland env file in place. - Run
litestream restoreto rebuilddata.dbfrom R2. - Start PocketBase against the restored data directory.
- Start the Litestream service so the new box begins replicating again.
Rehearse this before you need it. Spin up a throwaway instance, restore a real database into it, start PocketBase, and confirm the row counts match. Do it once on a calm afternoon, so the day a box actually dies your hands already know the steps and you are not reading docs while users wait. I treat an unrehearsed restore as equivalent to no backup.
What broke: config that lied about runtime
The expensive lesson in my setup was not Litestream itself. It was a pattern: configuration that claims to be working while silently doing nothing.
The clearest case was a backup health check that wrote its success timestamp before the sync actually ran. Every dashboard was green. The backup had been silently dead for days, and the monitor reported all good because it marked success at the wrong moment. Data was not being copied and nothing told me.
The fix was to make success contingent on the real thing happening. The health signal now fires only after the sync command exits cleanly, never before. It sounds obvious written down, and it is exactly the kind of thing that quietly rots in production.
Carry the same suspicion to Litestream. Do not trust that replication is happening because the service is active. Verify it against R2:
- Confirm Litestream wrote a snapshot recently in the logs, not just that the process is up.
- Use
litestream snapshots -config /etc/litestream.yml /pb/pb_data/data.dbto list what is actually in the bucket. - Periodically run a real restore into a scratch path and diff it against live.
A service that is running is not the same as a service that is working. The only proof that your replication works is a restore you watched succeed.
What it costs
For my workload, effectively nothing, and the why matters more than the headline.
R2's free tier gives a standing block of storage plus a monthly allowance of Class A operations (writes) and Class B operations (reads). A few small SQLite databases, each snapshotting on a multi-hour interval and streaming small WAL segments in between, generate modest writes and tiny storage. A 72-hour retention window keeps enough history for point-in-time restore without hoarding old generations, so storage stays small and predictable.
The cost that would have hurt elsewhere is the restore download, and on R2 that egress is free. So the real bill for replicating a handful of bootstrapped databases sits at or near zero, which is the only number that makes sense when the compute under it is also zero. Scale to large databases or aggressive snapshot intervals and you will eventually cross into paid territory, but the operations count warns you long before it stings.
When NOT to use Litestream
Litestream is replication, not a clustering or high-availability layer. Know its edges before you lean on it:
- It assumes a single writer. SQLite and PocketBase are single-writer by design, so this matches, but it means Litestream is not a way to run two app servers writing the same database. It is disaster recovery, not horizontal scale.
- It is not a substitute for a database that genuinely needs a managed cluster with synchronous replicas and automatic failover. If your data layer has outgrown one box, the answer is a different backend, not Litestream stretched past its job.
- Restore is a deliberate recovery step, not an instant transparent failover. There is a human-initiated gap between the box dying and the restore completing. For most bootstrapped apps that gap is fine. For anything with a real-time uptime contract, it is not.
- The recovery point is bounded by how recently the last WAL frames reached R2. It is small, but it is not zero. If you cannot tolerate losing the last few writes, you need synchronous replication, which is a different tool entirely.
For the case it fits, a SQLite or PocketBase app on cheap infrastructure where you want to survive losing the whole machine, it is the cleanest answer I have found. It turned my biggest infrastructure risk into a one-command recovery, and it costs nothing to run.
Related
More Automation

Cloudflare API Token Gotchas: The PUT That Wiped Mine Twice
I broke production twice by updating a Cloudflare token's scopes through the public API, then learned the wrangler auth fix and a secret-scrub habit the hard way. This is exactly what bit me and how I handle tokens now.

Fix NVIDIA Cursor and Video Stutter on Linux: GPU Clock Thrash
Cursor jitter and dropped video frames on NVIDIA Linux get blamed on the compositor every time. On my GTX 1660 the real cause was the driver bouncing graphics and VRAM clocks under light load. Here is the fix that held.

Memoize Your LLM Calls to Stop Burning Quota on Answers You Have
Re-running the same deterministic prompt in dev and CI burns quota for an answer you already got. Here is the memoization wrapper I use to replay it from disk at zero tokens, and the one place I never point it.