Production-shaped data in a preview branch, without the personal data
A branch clones everything, including the data you are not allowed to copy. The sanitised-parent pattern, and why masking after the fact is the wrong order.
An Infrai branch created with POST /v1/db/branch/create is a clone of its parent — schema, indexes, statistics and every row. That’s exactly what makes it useful for testing and exactly what makes branching straight off production a data-protection problem: every real email address, phone number and payment reference lands in an environment that developers can read and CI can log.
The pattern that works is to branch from a sanitised parent, never from production. Order matters, and this page is about why.
Why masking afterwards is the wrong order
The instinct is: branch from production, then run an anonymisation script against the branch. It’s wrong for three reasons, and they’re all timing.
The data is already there. Between the clone completing and your script finishing, real personal data exists in a preview database — and if the script fails halfway, it stays there in a state nobody can describe. Your audit answer becomes “we usually delete it”.
The script runs N times. One branch per pull request means the anonymisation runs per branch, so a bug in it is a per-branch exposure rather than a single incident you fix once.
And it can’t be verified cheaply. Proving that no branch anywhere holds a real address means checking every branch, forever.
Sanitising once, upstream, inverts all three: the only clone operation is from data that was never sensitive.
The sanitised-parent pattern
production (never branched from)
│ nightly, one-way
▼
seed-sanitised ──branch──▶ pr-1482
──branch──▶ pr-1490
You maintain one extra project whose contents are production-shaped but not production-derived in any identifying way. Every preview branches from it.
curl -sS -X POST "https://api.infrai.cc/v1/db/project/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "seed-sanitised", "plan": "hobby", "postgres_version": "17"}'
{
"ok": true,
"data": {
"project_id": "dbp_5nRqLmT4xBzY2fVc",
"db_id": "db_8hJk1pWsQnD9rGtU",
"name": "seed-sanitised",
"vendor": "tencent_pg",
"plan": "hobby",
"state": "ready",
"primary_branch": "main",
"created_at": "2026-09-21T03:10:00Z"
}
}
Then every preview branch forks from that project instead:
curl -sS -X POST "https://api.infrai.cc/v1/db/branch/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"project_id": "dbp_5nRqLmT4xBzY2fVc", "branch_name": "pr-1482", "from_branch": "main"}'
What “production-shaped” has to preserve
Anonymised data is only useful if it still breaks the things production breaks. Preserve:
| Property | Why a test needs it |
|---|---|
| Row counts and distribution | a query plan on 50 rows tells you nothing |
| Referential integrity | orphaned foreign keys produce fake bugs |
| Value shapes and lengths | a 200-character name finds the layout bug |
| Cardinality of enums and statuses | the rare status is the one with the bug |
| Nulls where nulls occur | the NULL handling path is where crashes live |
Replace values, keep structure. A generated address should look like an address, be about as long as a real one, and appear roughly as often; a column of xxxxx passes a privacy review and fails to catch anything.
Refreshing the seed
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SEED_PROJECT = os.environ["INFRAI_SEED_PROJECT_ID"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def snapshot_seed(label: str) -> dict:
"""Snapshot the sanitised seed before rebuilding it, so a bad generation run
is recoverable without touching production."""
resp = SESSION.post(f"{API}/v1/db/snapshot/create",
json={"project_id": SEED_PROJECT, "label": label}, timeout=60)
resp.raise_for_status()
snap = resp.json()["data"]
deadline = time.monotonic() + 600
while snap.get("state") != "ready" and time.monotonic() < deadline:
time.sleep(5)
check = SESSION.get(f"{API}/v1/db/snapshot/get/{snap['snapshot_id']}", timeout=25)
check.raise_for_status()
snap = check.json()["data"]
return snap
def audit(note: str, seed_project: str) -> None:
SESSION.post(f"{API}/v1/logs/ingest",
json={"entries": [{"level": "info", "message": note,
"attributes": {"seed_project_id": seed_project}}]},
timeout=20)
if __name__ == "__main__":
snap = snapshot_seed(f"seed-refresh-{time.strftime('%Y%m%d')}")
audit("sanitised seed snapshotted before regeneration", SEED_PROJECT)
print(f"seed snapshot {snap['snapshot_id']} state={snap.get('state')}")
Run the regeneration on a schedule with POST /v1/cron/create. A seed that hasn’t been refreshed in six months stops resembling production, and the tests that pass against it stop meaning anything.
The bits a database branch can’t sanitise
This is the limitation worth being explicit about: your data isn’t only in Postgres. A preview environment that branches a sanitised database and then reads production object storage, production analytics or production user records has copied nothing and leaked everything.
So the same discipline applies across the account, which is easier precisely because it’s one account: give the preview its own bucket rather than production’s (POST /v1/storage/bucket/create), point it at a separate queue (POST /v1/queue/create), and if it needs users, create throwaway ones with POST /v1/auth/user/create instead of copying the directory. One credential means one place to check that every dependency is the preview’s own — with six vendors, the audit is six audits and the one you forget is the one that matters.
Honest boundaries
Nothing here anonymises data for you. There’s no masking feature, no synthetic-data generator, no column classifier — the generation script is yours to write and to keep current as your schema changes, and that’s real ongoing work rather than a one-off.
Neon and Supabase don’t solve this either, to be fair; branching is a cloning primitive everywhere, and the sanitised-parent pattern is the standard answer regardless of vendor. If you want data masking as a product rather than a pipeline, that’s a different category of tool.
Branch and snapshot creation rates are live in GET /v1/discovery/db.branch.create and your accrual in GET /v1/account/usage (verified 2026-09-21); a seed project plus its snapshots is a standing GB-month cost, so keep the retention tight. Those rates drift downward over time rather than up.