Migrating from Snaplet to PrivaCI
Snaplet shut down on August 31, 2024. The team open-sourced its packages and handed maintenance of the generation client to the Supabase community organization, but the community snapshot repository is now archived and is not a maintained path forward for production anonymization workflows.
Snaplet handled two distinct jobs: it captured real production data,
anonymized it, and restored it to a local or staging database; and
separately it could generate synthetic seed data from scratch for
populating empty databases. PrivaCI covers the capture-and-anonymize half only. If
your Snaplet workflow ran snaplet snapshot capture against a real
production database, transformed PII columns in snaplet.config.ts, and restored the result to staging — this guide covers that migration.
If you were using snaplet seed to generate rows from a schema without
a production source, PrivaCI does not replace that workflow.
For the capture-and-anonymize path — the common snaplet alternative for teams leaving Snaplet — the migration maps cleanly.
What your Snaplet config looked like
Snaplet's transform configuration lived in a TypeScript file using the @snaplet/copycat library for deterministic value generation. A
typical config for a SaaS platform with users, addresses, and payment data:
// .snaplet/config.ts
import { copycat } from "@snaplet/copycat";
import { defineConfig } from "@snaplet/sdk";
export default defineConfig({
transform: {
public: {
users: ({ row }) => ({
email: copycat.email(row.id),
full_name: copycat.fullName(row.id),
phone: copycat.phoneNumber(row.id),
date_of_birth: null,
avatar_url: null,
}),
addresses: ({ row }) => ({
line1: copycat.streetAddress(row.id),
city: copycat.city(row.id),
// postcode left as-is for geographic distribution in tests
}),
payments: ({ row }) => ({
card_last4: "9999",
billing_name: copycat.fullName(row.id),
billing_email: copycat.email(row.id),
}),
},
},
subset: {
targets: [
{ table: "public.users", percent: 10 },
],
},
}); The workflow was two commands:
snaplet snapshot capture --db-url=$SOURCE_DATABASE_URL # snapshot production
snaplet snapshot restore --latest # restore to local/staging snapshot capture read the source, applied the transforms, and wrote
a snapshot artifact. snapshot restore loaded it into the target.
Two commands, no persistent service.
Translating copycat transforms to mask-rules.yaml
PrivaCI's mask-rules.yaml covers the same column-level transforms,
with YAML in place of TypeScript. copycat functions achieved determinism
by hashing the seed value (usually row.id); PrivaCI achieves determinism through an explicit global_salt
combined with the row's primary key. The outcome is identical — the same source
row always maps to the same masked value — but the salt is an explicit secret
you control rather than an internal implementation detail.
For the full provider list, see the configuration reference.
| Snaplet / copycat | PrivaCI action |
|---|---|
copycat.email(row.id) | { action: fake, provider: email } |
copycat.fullName(row.id) | { action: fake, provider: full_name } |
copycat.firstName(row.id) | { action: fake, provider: first_name } |
copycat.lastName(row.id) | { action: fake, provider: last_name } |
copycat.phoneNumber(row.id) | { action: fake, provider: phone } |
copycat.streetAddress(row.id) | { action: fake, provider: street } |
copycat.city(row.id) | { action: fake, provider: city } |
copycat.username(row.id) | { action: fake, provider: username } |
copycat.url(row.id) | { action: hash } — no built-in URL provider; hash gives
a stable opaque value
|
copycat.word(row.id) | { action: fake, provider: company } — or { action: hash } |
copycat.scramble(row.value) | { action: regex_mask, pattern: "..." } |
null (literal) | { action: null } |
"9999" (literal) | { action: static, value: "9999" } |
| (column omitted from transform) | (omit column — passthrough is the default) |
Note on copycat.streetAddress vs PrivaCI's address
provider: the address provider emits a full one-line address including
city, postcode, and country. For a line1-style column that
only holds the street component, use provider: street — otherwise
city and postcode end up concatenated into the street field.
Snaplet's TypeScript config could express row-conditional logic with
arbitrary JavaScript. On Commercial (Standard and Compliance), PrivaCI supports declarative CEL when: guards on column actions
— for example mask notes only when status == 'closed'. That covers common row predicates without
embedding JavaScript. Arbitrary JS, cross-column transforms, and custom
hashing still have no direct equivalent — review those columns in dry-run and map them to a static rule or keyed action.
The equivalent mask-rules.yaml for the Snaplet config above:
# mask-rules.yaml
version: "1.0"
global_salt: env://ANONYMIZATION_SALT
strict_autodetect: true
on_existing_data: fail
tables:
public.users:
strategy: transform
columns:
email: { action: fake, provider: email }
full_name: { action: fake, provider: full_name }
phone: { action: fake, provider: phone }
date_of_birth: { action: null }
avatar_url: { action: null }
public.addresses:
strategy: transform
columns:
line1: { action: fake, provider: street }
city: { action: fake, provider: city }
# postcode omitted — passthrough is the default
public.payments:
strategy: transform
columns:
card_last4: { action: static, value: "9999" }
billing_name: { action: fake, provider: full_name }
billing_email: { action: fake, provider: email }
strict_autodetect: true causes dry-run to exit non-zero
if any column matches the PII pattern library but has no explicit YAML rule
— enforcing masking policy as code rather than relying on auto-detect alone.
Add explicit passthrough rules for columns you intentionally leave unchanged.
What replaces snapshot capture and restore
Snaplet's two-step snapshot capture → snapshot restore
workflow collapses into a single run. PrivaCI reads directly
from the source, masks in the streaming pipeline, and writes to the target in
one pass. There is no intermediate snapshot artifact on disk or in cloud storage.
The image's entrypoint is already privaci, so commands go
directly after the image name:
export ANONYMIZATION_SALT="$(openssl rand -hex 32)"
export SOURCE_DB_URL="postgresql://user:pass@prod-replica:5432/app"
export TARGET_DB_URL="postgresql://user:pass@staging:5432/app"
# Verify the masking plan — no writes
docker run --rm \
-e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
-v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
ghcr.io/boundarylogic/privaci:1.3.0 \
dry-run --config /config/mask-rules.yaml
# Run the mask
docker run --rm \
-e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
-v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
ghcr.io/boundarylogic/privaci:1.3.0 \
run --config /config/mask-rules.yaml
dry-run reads the source catalog, builds the FK dependency graph,
runs PII auto-detection over any columns not covered by your YAML, and exits
without writing. Review the output before the first real run.
PrivaCI is a stateless container — no Node.js runtime to manage, no global
CLI to install, no .snaplet directory in the repository. The image
and the YAML file are the only artifacts.
Migrating Snaplet's subset configuration
Subsetting requires the Compliance tier. Snaplet's percent-based subsetting selected a random sample from the root table. PrivaCI Commercial's subsetting uses SQL predicates with the commercial image and
a commercial-extensions.yaml file, passed alongside the masking config.
On AWS Marketplace, attach an IAM task role that can verify your subscription
at container start — see deployment options.
docker run --rm \
-e SOURCE_DB_URL -e TARGET_DB_URL -e ANONYMIZATION_SALT \
-v "$PWD/mask-rules.yaml:/config/mask-rules.yaml:ro" \
-v "$PWD/commercial-extensions.yaml:/config/commercial-extensions.yaml:ro" \
ghcr.io/boundarylogic/privaci-commercial:1.0.13 \
run \
--config /config/mask-rules.yaml \
--commercial-extensions /config/commercial-extensions.yaml
# commercial-extensions.yaml
version: "1.0"
subset:
- table: public.users
predicate: "created_at >= now() - interval '90 days'"
A percent: 10 Snaplet subset translates to a time window or an
explicit predicate depending on what distribution your tests actually need.
Time windows are usually more useful than random percentages — they carry realistic
patterns and include edge cases that accumulate over time.
PrivaCI resolves the FK closure automatically from the root predicate. PrivaCI
reads the FK graph from pg_catalog and pulls dependent rows without
additional configuration.
For local development workflows — where Snaplet was typically run to
produce a small slice for each developer — the CI ephemeral guide
covers the equivalent pattern as a GitHub Actions job that spins up a masked
Postgres service container per pull request.
What PrivaCI adds beyond Snaplet's anonymization scope
FK graph traversal by default. PrivaCI builds a full dependency graph from pg_catalog at run
start, sorts tables topologically, and defers constraints to break cycles —
FK integrity is handled automatically.
Crash-safe resume. PrivaCI writes a per-batch checkpoint to the target database. A run interrupted
mid-table resumes from the exact batch where it stopped. Snaplet's snapshot
model had no resume — an interrupted capture meant starting over.
Signed compliance reports (Commercial). After a run, the run
UUID is printed in the output and stored in _privaci.runs on the
target. Pass it to the commercial image to generate a tamper-evident report.
Set PRIVACI_REPORT_SIGNING_KEY_PEM to your Ed25519 private key; without
it the output is unsigned JSON (fine for dev).
docker run --rm \
-e TARGET_DB_URL \
-e PRIVACI_REPORT_SIGNING_KEY_PEM \
-v "/tmp:/tmp" \
ghcr.io/boundarylogic/privaci-commercial:1.0.13 \
report --run <run-uuid> --format json --output /tmp/report.json
Schema drift detection (Commercial). detect-drift
flags new PII columns added since the last run before they reach a lower environment
— the failure mode that silent passthrough never catches.
What PrivaCI doesn't cover
Snaplet seed / synthetic generation. PrivaCI requires a live source database. If your workflow used snaplet seed to generate rows from a schema definition without a production source, that
use case is not covered.
Snapshot artifacts. Snaplet's snapshots could be committed
to version control or shared across a team as files. PrivaCI writes directly
to a target database; there is no intermediate dump artifact.
TypeScript / JavaScript transform logic. Conditional or computed
transforms written as JavaScript functions in snaplet.config.ts need to be expressed as declarative YAML rules or handled outside PrivaCI.
Next steps
The OSS engine covers the core anonymization workflow and is free under
ELv2. Run dry-run against your source schema to see which columns auto-detect
flags before you write a line of config:
docker run --rm ghcr.io/boundarylogic/privaci:1.3.0 --help
For a scheduled staging refresh, see the staging guide.
For tenant-scoped subsetting from a production replica, see the support repro guide. Commercial features — subsetting, signed reports, drift detection,
JSONB path masking — are available from $149/month on AWS Marketplace (Standard and Compliance
tiers).