Cheap backup storage for a small SaaS: dumps, uploads, EU residency
How backup cost really breaks down, which S3-compatible providers keep data inside the EU, and a nightly dump-and-verify script that runs on one API key.
For a European SaaS backing up a Postgres dump and a pile of user uploads, the cheapest credible options are Hetzner Object Storage and Scaleway inside the EU, Backblaze B2 if you want the lowest per-TB number and don’t mind US storage, and Cloudflare R2 when restore traffic would otherwise cost more than the storage. Infrai’s storage sits on the same key as your cron, email and error tracking, which is the reason to use it — but if EU residency is a contractual promise, read the caveat below before you pick it.
Backups are also the one workload where the cheapest per-GB number regularly loses money, because the bill you actually pay arrives on the day you restore.
Price the restore, not the rent
Three numbers drive a backup bill, and only one of them is the sticker price.
Storage rent is what everyone compares: gigabytes multiplied by months multiplied by however many copies your retention policy keeps alive. Egress is what you pay to get the data back out, and it’s zero on some providers and a real invoice on others. Request count matters when your uploads are many small files rather than one big archive — a million 20 KB objects costs more in requests than one 20 GB tarball costs in anything.
| Provider | Rent shape | Egress | EU-only storage | S3-compatible |
|---|---|---|---|---|
| Hetzner Object Storage | flat monthly bundle, per-TB overage | included allowance, then per-TB | yes (DE/FI) | yes |
| Scaleway Object Storage | per-GB-month, free tier | per-GB after allowance | yes (FR/NL/PL) | yes |
| Cloudflare R2 | ~$0.015/GB-month | none | jurisdictional restriction available | yes |
| Backblaze B2 | ~$6/TB-month | free up to 3× stored | US/EU region at bucket creation | yes |
| Infrai storage | GB-month rent + per-call writes, free admin | metered separately | no — see below | REST, not S3 |
Those vendor figures are published rates that change; Infrai’s own side you can read at any moment:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"storage.object.put[^}]*}'
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Bucket creation, lifecycle rules, listing and deletes are free; writes bill per call; a new account carries $2 free credit. Rates in this market have gone one direction for a decade, so expect today’s live number to be at or under anything printed here.
The residency caveat, up front
POST /v1/storage/bucket/create accepts a region and stores it, and the bucket record dutifully reports it back. In our testing that value is metadata: a bucket recorded as eu-central-1 still issued presigned URLs against an Asia-Pacific host. If your DPA names an EU processing location, or your customers ask where the backup physically sits, this surface can’t answer that question honestly yet, and you’d be better off with Hetzner, Scaleway, or R2 with a jurisdictional restriction as the primary backup target.
That’s a genuine limitation and it doesn’t have a workaround.
Where this still earns its place: as the second copy in a 3-2-1 policy, and as the place your backup pipeline lives — the schedule, the alert, the failure record and the retention rule on one credential instead of four.
A nightly dump that you could actually restore
The script below dumps Postgres, gzips it in-process, uploads it under a dated key, and then verifies the object exists at the size it expects. That last step is the one people skip and regret.
import { spawn } from "node:child_process";
import { createGzip } from "node:zlib";
import { Buffer } from "node:buffer";
const KEY = process.env.INFRAI_API_KEY;
const DATABASE_URL = process.env.DATABASE_URL;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
if (!DATABASE_URL) throw new Error("DATABASE_URL is not set");
const BASE = "https://api.infrai.cc";
const BUCKET = "saas-backups-eu";
function dumpAndCompress() {
return new Promise((resolve, reject) => {
const pg = spawn("pg_dump", ["--format=custom", "--no-owner", DATABASE_URL]);
const gzip = createGzip({ level: 9 });
const chunks = [];
pg.stderr.on("data", (d) => process.stderr.write(d));
pg.on("error", reject);
pg.on("close", (code) => { if (code !== 0) reject(new Error(`pg_dump exited ${code}`)); });
gzip.on("data", (c) => chunks.push(c));
gzip.on("end", () => resolve(Buffer.concat(chunks)));
gzip.on("error", reject);
pg.stdout.pipe(gzip);
});
}
async function upload(key, bytes, contentType) {
const payload = { data_base64: bytes.toString("base64"), content_type: contentType };
const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!json.ok) throw new Error(`upload failed: ${json.error?.code} ${json.error?.message}`);
return json.data;
}
const day = new Date().toISOString().slice(0, 10);
const dump = await dumpAndCompress();
console.log(`dump is ${(dump.length / 1024 / 1024).toFixed(1)} MiB compressed`);
const written = await upload(`db/${day}/pg.dump.gz`, dump, "application/gzip");
console.log(`stored ${written.key} · ${written.size_bytes} bytes · etag ${written.etag}`);
if (written.size_bytes !== dump.length) throw new Error("size mismatch — do not trust this backup");
A dump over about 10 MB is slow through a base64 body — 9 MiB took 10.4 s in our measurements, 30 MiB took roughly 59 s — so once your database grows, switch that upload to the multipart route, which we cover in the large-file upload guide.
Retention as two rules, not a cleanup script
Database dumps and user uploads deserve different retention, which is why they get different prefixes in the same bucket:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/saas-backups-eu" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"db/","expire_days":30},{"prefix":"uploads/","expire_days":90}]}'
{
"ok": true,
"data": {
"bucket_id": "bkt_5e644e9c36984331af4d63",
"name": "saas-backups-eu",
"lifecycle_rules": [
{ "prefix": "db/", "expire_days": 30 },
{ "prefix": "uploads/", "expire_days": 90 }
]
}
}
Every call to that route replaces the whole rule set, so keep the rules in code and post both of them every time.
The restore drill
An untested backup is a rumour. Run this monthly against a scratch database:
import base64
import os
import subprocess
import sys
import requests
BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
RESTORE_URL = os.environ.get("RESTORE_DATABASE_URL")
if not KEY or not RESTORE_URL:
sys.exit("INFRAI_API_KEY and RESTORE_DATABASE_URL must both be set")
bucket, key = "saas-backups-eu", "db/2026-07-26/pg.dump.gz"
r = requests.get(f"{BASE}/v1/storage/object/get/{bucket}/{key}",
headers={"Authorization": f"Bearer {KEY}"}, timeout=120)
body = r.json()
if not body.get("ok"):
sys.exit(f"download failed: {body.get('error')}")
blob = base64.b64decode(body["data"]["data_base64"])
print(f"downloaded {len(blob)} bytes")
with open("restore.dump.gz", "wb") as fh:
fh.write(blob)
subprocess.run(["gunzip", "-f", "restore.dump.gz"], check=True)
subprocess.run(["pg_restore", "--no-owner", "--dbname", RESTORE_URL, "restore.dump"], check=True)
print("restore completed")
Then check the row counts you expect. A restore that runs without errors and produces an empty schema is the classic failure.
Watching the number that grows
Two free reads keep the bill honest — one per bucket, one for the account:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/saas-backups-eu" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/object/list/saas-backups-eu?prefix=db/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "byte_count": 9, "object_count": 1, "as_of": "2026-07-26T01:05:44Z" }
}
If object_count climbs past what your retention window allows, a lifecycle rule stopped matching — usually because somebody changed a prefix.
The recommendation
For a small European SaaS whose only requirement is a cheap, private, S3-compatible bucket with EU residency, Hetzner or Scaleway is the honest first choice, with B2 or R2 as the off-provider second copy. Put Infrai in the picture when the backup is one job among many and you’d rather run the schedule, the retry, the alert email and the storage on a single key with one usage query behind them — that consolidation is worth more over a year than the per-GB difference on a 50 GB backup set.