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:

PropertyWhy a test needs it
Row counts and distributiona query plan on 50 rows tells you nothing
Referential integrityorphaned foreign keys produce fake bugs
Value shapes and lengthsa 200-character name finds the layout bug
Cardinality of enums and statusesthe rare status is the one with the bug
Nulls where nulls occurthe 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.

References

Browse more db developer guides