Snapshot backups to Cloudflare R2 without the S3 SDK
For backing up small state files (a SQLite control.db, config, keys), Cloudflare R2's S3-compatible API drags in a client, signing and a separate pair of S3 credentials. The plain Cloudflare REST API can PUT and GET objects directly — one curl, one API token, no SDK:
curl -sS -X PUT "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/r2/buckets/$BUCKET/objects/$KEY" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @snapshot.tar.age -H "Content-Type: application/octet-stream"
GET is the same with -o snapshot.tar.age, DELETE with -X DELETE. No egress fees on R2, and the free tier (10 GB storage, 1M Class A writes/month) dwarfs an hourly-snapshot workload.
Minting the token needs no dashboard click either — the Global API Key can do it through the API. The shape that finally worked (after a few 400s worth of resources-expression spelunking):
curl -sS -X POST "https://api.cloudflare.com/client/v4/user/tokens" \
-H "X-Auth-Key: $CF_API_KEY" -H "X-Auth-Email: $CF_API_EMAIL" \
-H "Content-Type: application/json" --data '{
"name": "snapshots-backup",
"policies": [{"effect": "allow",
"resources": {"com.cloudflare.api.account.<ACCOUNT_ID>": "*"},
"permission_groups": [
{"id": "b4992e1108244f5d8bfbd5744320c2e1"},
{"id": "bf7481a1826f439697cb59a20b22293e"}
]}],
"expires_on": "2027-10-17T00:00:00Z"
}'
Those two group ids are Workers R2 Storage Read and Write. The response carries the token value exactly once — pipe it straight to its destination file (umask 077, owner root, 0600) and never let it touch a terminal or a shell variable you might echo.
Two traps when verifying from the host that holds the credential:
ssh -nredirects stdin from/dev/nulland silently wins over< file— the credential file lands 0 bytes. Drop-nwhen piping stdin.- If the credential file is root-only,
curl -H "Bearer $(cat ...)"in your ssh shell reads nothing and the API answers9106: Missing Authorization header. Run the whole probe undersudo sh -cso the expansion happens where the file is readable.
Encrypt before upload so the host leak doesn't matter: age with the public key on the host and the private key in your password manager. Restore = GET on the operator machine, decrypt, PRAGMA integrity_check.