Most Redis outages that end in data loss don't start with a missing backup. They start with a backup that restored into an empty database, because of one detail almost nobody knows until it bites them:
If AOF is enabled, Redis ignores your dump.rdb on boot.
You stop Redis, drop in the RDB file you carefully backed up, start it, run DBSIZE, and get 0. The file is fine. Redis just never looked at it — with appendonly yes, the append-only file is the source of truth and the RDB is decoration. People discover this mid-incident, decide the backup was corrupt, and give up on a perfectly good file.
So before anything else, find out what your server actually does:
redis-cli CONFIG GET appendonly # "yes" → AOF is authoritative on boot
redis-cli CONFIG GET save # RDB snapshot triggers, empty = disabled
redis-cli CONFIG GET dir # where both live
redis-cli CONFIG GET dbfilename # usually dump.rdb
The rest of this guide builds a pipeline that works whichever answer you got:
- Take a consistent snapshot
- Get it off the box: encrypted, deduplicated, versioned
- Prove it restores (including the AOF trap)
- Retention and scheduling
1. Take a consistent snapshot
The clean way — pull it from a running server:
mkdir -p ./redis-dumps
redis-cli --rdb ./redis-dumps/dump.rdb
This asks Redis for a fresh RDB over the replication protocol and streams it to you. No filesystem access needed, no risk of catching a half-written file, works against a remote server. If you only take one thing from this guide, take this command.
If you must copy the file from disk, do not copy it blind. BGSAVE forks and writes in the background, so a naive cp can catch a partial file:
before=$(redis-cli LASTSAVE)
redis-cli BGSAVE
# LASTSAVE changes only when the fork finishes successfully.
while [ "$(redis-cli LASTSAVE)" = "$before" ]; do sleep 1; done
cp /var/lib/redis/dump.rdb ./redis-dumps/dump.rdb
Polling LASTSAVE is the supported way to know a background save actually completed. Never use SAVE as the trigger — it does the same work on the main thread and blocks every client until it finishes.
If you run AOF, back that up too, since it is what Redis will actually load:
redis-cli BGREWRITEAOF # compact it first
Note the layout changed in Redis 7: instead of a single appendonly.aof, you get a directory (appendonlydir by default) holding a base file, incremental files, and a manifest. Copy the whole directory — a manifest without its parts restores nothing.
cp -r /var/lib/redis/appendonlydir ./redis-dumps/
Keep the output paths stable. Overwrite the same files every run. That is what makes deduplicated, incremental uploads work in the next step: a 4 GB dataset with modest churn ships a fraction of its size each night.
2. Get it off the box
A dump on the Redis host dies with the Redis host. Push it somewhere with separate credentials.
Sign in at Backup Data, claim the free 5 GB workspace, mint an API key scoped backup:write, backup:read, snapshots:read, then:
npm install @lighthouse-web3/baas-js-sdk
export LH_API_KEY="lh_xxxxxxxxxxxxxxxxxxxxxxxx"
export LH_WORKSPACE_ID="your-workspace-uuid"
import { BackupClient } from "@lighthouse-web3/baas-js-sdk";
const client = new BackupClient({
apiKey: process.env.LH_API_KEY,
workspaceId: process.env.LH_WORKSPACE_ID,
});
const snapshot = await client.backup(["./redis-dumps"], {
description: "nightly redis snapshot",
tags: { type: "redis", env: "prod", host: "cache-01" },
});
console.log(`snapshotId=${snapshot.snapshotId} totalSize=${snapshot.totalSize}`);
(There's a Go SDK with the same surface.)
Each run is an immutable point-in-time snapshot, and content-defined chunking means a barely-changed RDB uploads the delta rather than the whole file.
Redis holds sessions, tokens, and cached user records far more often than its reputation as "just a cache" suggests. Encrypt client-side:
import { generateKeyfile } from "@lighthouse-web3/baas-js-sdk";
// once:
generateKeyfile("/secure/lh.keyfile", process.env.LH_KEYFILE_PASSPHRASE);
// then on every backup:
await client.backup(["./redis-dumps"], {
description: "nightly-encrypted",
encryption: {
keyfilePath: "/secure/lh.keyfile",
passphrase: process.env.LH_KEYFILE_PASSPHRASE,
},
});
AES-GCM on your machine; the server stores only ciphertext. You hold the keys — lose the keyfile and passphrase and the data is unrecoverable, with no server-side reset.
3. Prove it restores
Restore the files, then walk directly into the trap this guide opened with.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const target = mkdtempSync(join(tmpdir(), "redis-restore-drill-"));
await client.restore(snapshot.snapshotId, target, {
onProgress: (e) => console.log(`[${e.phase}] ${e.current}/${e.total}`),
});
Every restore verifies chunk integrity against expected checksums before reassembly.
Restoring an RDB into a server with AOF enabled. This is the sequence that actually works, and it is not obvious:
# 1. Start a throwaway Redis with AOF OFF, so the RDB is what gets loaded.
docker run -d --name redis_restore_test \
-v "$TARGET/redis-dumps":/data:ro \
redis:7 redis-server --appendonly no --dir /data --dbfilename dump.rdb
# 2. Confirm the data is actually there.
docker exec redis_restore_test redis-cli DBSIZE
docker exec redis_restore_test redis-cli INFO keyspace
If you need AOF back on afterwards, turn it on at runtime rather than restarting with it enabled — that way Redis rewrites the AOF from the data currently in memory, instead of booting from a stale AOF and discarding your restore:
docker exec redis_restore_test redis-cli CONFIG SET appendonly yes
Restoring an AOF directory is the mirror image: copy the whole appendonlydir into place and start with --appendonly yes. A partial directory, or a manifest whose referenced files are missing, fails to load.
Then check the data, not just the count. DBSIZE proves keys exist; it does not prove they are the right keys:
docker exec redis_restore_test redis-cli --scan --count 20 | head
docker exec redis_restore_test redis-cli TTL some:known:key
That last one matters. RDB preserves TTLs as absolute expiry times, so keys that expired while the backup sat in storage are gone the moment you load it. A restore of week-old session data will look emptier than the backup, and that is correct behaviour, not corruption.
Time the whole thing. That number is your real RTO — see RPO vs RTO for why writing it down matters more than estimating it.
The drill checklist:
- ✅ Restore into a throwaway server, never over production
- ✅ Start with
--appendonly nowhen restoring from RDB - ✅ Enable AOF at runtime afterwards, not via restart
- ✅ Copy the entire
appendonlydir, not just the manifest - ✅ Check
DBSIZE, then spot-check real keys and their TTLs - ✅ Record wall-clock restore time, keep a log across drills
- ✅ Remove the test container when done
4. Retention and scheduling
const before = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
// sourceId identifies the machine and path a backup came from; every snapshot
// carries it. Retention is currently applied across the whole workspace, so
// keepLatest counts every snapshot in it, not just this source's.
const policy = { sourceId: snapshot.sourceId, keepLatest: 14, before };
const preview = await client.pruneSnapshots({ ...policy, dryRun: true });
console.log("would delete:", preview.snapshotIds);
await client.pruneSnapshots({ ...policy, dryRun: false });
# /etc/cron.d/redis-backup (nightly at 02:20)
20 2 * * * backup /opt/backup/redis-backup.sh >> /var/log/redis-backup.log 2>&1
Monthly, run the drill. It is the only part of this that tests anything.
Is Redis even worth backing up?
Sometimes genuinely not. If Redis holds nothing but a cache that rebuilds from Postgres on a miss, the correct backup strategy is a warm-up script and no backups at all.
But "it's just a cache" is a claim worth checking rather than assuming. Run this:
redis-cli INFO keyspace
redis-cli --scan --count 100 | head -50
If you see session tokens, rate-limit counters, job queues, feature flags, or anything a user would notice losing, it is a database that happens to be fast. Back it up like one.
The pipeline, complete
| Step | Command / call | Proves |
|---|---|---|
| Inspect | CONFIG GET appendonly | You know what Redis loads on boot |
| Snapshot | redis-cli --rdb | The copy is consistent |
| AOF | BGREWRITEAOF + copy the directory | The authoritative file came too |
| Off-box | client.backup([...]) | It survives losing the host |
| Restore drill | fresh server, --appendonly no | It actually restores |
| Retention | client.pruneSnapshots({...}) | Storage stays bounded |
Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.