Restic + Cloudflare R2: Offsite Backups I Actually Trust
I've been burned by backup strategies that looked solid until they weren't. Restic and R2 fixed that for me — encrypted, versioned, and cheap enough to run hourly.
Three years ago I had a client lose a week of production data. Their hosting provider had backups — on the same SAN that failed. Lesson learned the hard way: backup infrastructure that shares fate with the thing it's backing up is not backup infrastructure. Since then I've standardized on restic pointed at Cloudflare R2, and I haven't lost sleep over a backup since.
The actual problem
Most small-to-midsize shops I work with have one of three backup situations:
- Daily mysqldump to the same server. Feels like a backup. Isn't.
- Hosting panel "snapshots" that live on the same physical infrastructure.
- S3 with aws-cli, which works but gets expensive fast if you're running hourly and keeping meaningful retention.
What I needed was something that:
- Encrypts data client-side before it leaves the machine (I manage servers for healthcare clients — this isn't optional)
- Does deduplication so hourly backups don't multiply storage costs linearly
- Has a proper retention policy engine
- Is cheap enough that I'd actually run it frequently
- Stores to object storage I can trust won't vanish
Restic hits every one of those. Cloudflare R2 is what makes it economical — no egress fees, S3-compatible API, and storage at $0.015/GB/month. For most of my clients, a full month of hourly backups with 30-day retention costs less than a nice lunch.
Setting it up
Restic speaks S3 natively, and R2 exposes an S3-compatible API. You need an R2 bucket and an API token with Object Read and Write permissions. Cloudflare's dashboard generates an access key ID and secret — same shape as AWS credentials.
Here's the environment setup I put in /etc/restic-env on each server, owned by root, mode 600:
export AWS_ACCESS_KEY_ID="your_r2_access_key_id"
export AWS_SECRET_ACCESS_KEY="your_r2_secret_access_key"
export RESTIC_REPOSITORY="s3:https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com/your-bucket-name"
export RESTIC_PASSWORD="a_strong_random_passphrase_stored_separately"
The RESTIC_PASSWORD is the encryption key for the repository. Store it somewhere other than the server — I keep client keys in 1Password with a note tying them to the server. If you lose this password, the backup is unrecoverable. That's the point. It also means Cloudflare can't read your data.
Initialize the repo once:
source /etc/restic-env
restic init
Then the actual backup script I drop at /usr/local/bin/restic-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
source /etc/restic-env
# Timestamp for logs
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Starting restic backup"
# Backup the paths that matter
restic backup \
/var/www \
/etc \
/home \
/root \
--exclude='/var/www/*/storage/logs' \
--exclude='/var/www/*/node_modules' \
--exclude='/var/www/*/.git' \
--exclude='*.sock' \
--tag "$(hostname)" \
--verbose
# Retention: keep last 24 hourly, 7 daily, 4 weekly, 6 monthly
restic forget \
--keep-hourly 24 \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] Backup complete"
Cron entry in /etc/cron.d/restic-backup:
15 * * * * root /usr/local/bin/restic-backup.sh >> /var/log/restic-backup.log 2>&1
I offset it to :15 so it doesn't stack with the top-of-hour traffic spike some apps see.
Handling databases
Files are easy. Databases need a dump first. For MySQL/MariaDB I prepend a dump step:
#!/usr/bin/env bash
set -euo pipefail
source /etc/restic-env
DUMP_DIR="/var/backups/mysql-dumps"
mkdir -p "$DUMP_DIR"
# Dump all databases
mysqldump \
--defaults-file=/etc/mysql/backup.cnf \
--all-databases \
--single-transaction \
--routines \
--triggers \
| gzip > "$DUMP_DIR/all-databases-$(date +%Y%m%d-%H%M%S).sql.gz"
# Keep only last 48 local dumps (safety net)
find "$DUMP_DIR" -name '*.sql.gz' -mmin +2880 -delete
# Now run restic including the dump dir
restic backup \
/var/www \
/etc \
/home \
"$DUMP_DIR" \
--exclude='/var/www/*/node_modules' \
--tag "$(hostname)"
restic forget \
--keep-hourly 24 \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
The /etc/mysql/backup.cnf has the credentials so they're not in the script:
[client]
user=backup_user
password=your_db_password
For Postgres it's the same pattern — pg_dumpall into a temp file, back it up with restic, clean up old dumps.
The gotchas that bit me
The --prune flag takes real time. On a busy repo with lots of small files, forget --prune can run for several minutes. It's rewriting pack files to remove unreferenced blobs. On one server with eight years of /var/www content, the first pruning run took 40 minutes. Not a disaster, but I didn't expect it. Schedule accordingly, and don't run prune every single hour — I actually split forget and prune, running forget hourly but prune only nightly.
R2's S3 endpoint requires the right region string. Restic uses us-east-1 as a default region for S3-compatible stores. R2 doesn't care about region in the usual sense, but you still need to set it or you'll get signing errors. Set AWS_DEFAULT_REGION=auto in your env file and it works.
export AWS_DEFAULT_REGION=auto
Check your restores. I set a monthly cron on a scratch VM that runs restic restore latest --target /tmp/restore-test and then spot-checks file counts and the most recent SQL dump. You would be shocked how many "working backup" setups fail silently. Restic's check command validates repository integrity, which helps, but actually restoring a file is the only real proof.
restic check --read-data-subset=5%
I run that weekly. It reads 5% of the data blobs and validates checksums without pulling the whole repo.
Lock files after crashes. If a backup process dies mid-run, restic leaves a lock file in the repo. The next run refuses to start. Mildly annoying. You'll see something like Fatal: unable to create lock in backend. Fix:
restic unlock
I add a pre-check in my script:
# Remove stale locks older than 2 hours
restic unlock --remove-all 2>/dev/null || true
This is safe because all my backups run from a single process per server. If you have concurrent backup processes legitimately, be more careful with this.
Exclude what you don't need. Node modules, build artifacts, .git directories, log files — they'll bloat your repo fast and slow down every subsequent backup. I maintain a project-level exclude file at /etc/restic-excludes and pass --exclude-file=/etc/restic-excludes to keep the script clean.
When I'd reach for this
This setup is my default for any Linux server I manage where I control the cron schedule. VPS clients, bare metal, managed hosting where I have shell access. It handles Laravel apps, WordPress installs, Python services, whatever's sitting in /var/www.
For healthcare and biotech clients I go a step further — I keep the R2 bucket in a separate Cloudflare account from any client credentials, so a compromised client account can't touch the backup bucket. The encryption means even that's just extra belt-and-suspenders.
I wouldn't reach for this if your data is already in a managed service that handles its own backup story well — PlanetScale, RDS with automated snapshots, Supabase. Don't back up what someone else is already backing up properly. But the files, the configs, the uploaded assets, the custom application code — that's on you.
I also wouldn't use this as a disaster recovery replacement for a full infrastructure-as-code story. Restic gets your data back. It doesn't rebuild your server. Combine it with Ansible or a provisioning script that can spin up a fresh box, then restore from restic, and you have an actual DR plan.
The cost reality
For a typical client with 50GB of active data, running hourly with 30-day retention, the actual stored size after dedup lands around 80-120GB. At R2's pricing that's $1.20-$1.80/month. No egress fees when I'm restoring from a Cloudflare-connected machine. For clients where I used to run S3 with comparable retention, I'm typically seeing 60-70% cost reduction.
The math works out because restic's deduplication is content-defined chunking — identical blocks across snapshots are stored once. Hourly backups of a mostly-static app are almost free to store after the first run.
I've had this setup running across a couple dozen servers for over two years now. I've done real restores after actual failures — a botched deployment that overwrote files, a ransomware attempt on a Windows VM that spread to a Samba share, a hosting provider outage where I needed to spin up elsewhere fast. Restic delivered every time.
If your backup strategy involves any single point of failure, you don't have a backup strategy. This combination is cheap enough that there's no excuse not to fix that today.
Need help shipping something like this? Get in touch.