---
title: "Deploy pr-postgres-anonymizer"
description: "Seed every Railway PR environment with a masked copy of production Postgres"
category: "Storage"
url: https://railway.com/deploy/pr-postgres-anonymizer
---

# Deploy pr-postgres-anonymizer

Seed every Railway PR environment with a masked copy of production Postgres

**[Deploy pr-postgres-anonymizer on Railway](https://railway.com/template/pr-postgres-anonymizer)**

Machine-readable deploy manifest (JSON, validated by TemplateCI): https://railway.com/deploy/pr-postgres-anonymizer/manifest.json

- **Creator:** AmitRK's Projects
- **Category:** Storage

## Template content

### seeder https://raw.githubusercontent.com/AmitKulkarni23/pr-postgres-anonymizer/main/assets/seeder.svg

- **Source:** https://github.com/AmitKulkarni23/pr-postgres-anonymizer
- **Start command:** `/app/seed.sh`

### masker https://raw.githubusercontent.com/AmitKulkarni23/pr-postgres-anonymizer/main/assets/masker.svg

- **Source:** https://github.com/AmitKulkarni23/pr-postgres-anonymizer
- **Start command:** `/app/schedule.sh`

## Buckets

- **pr-seed-dumps**

## Documentation

# Deploy and Host PR Postgres Anonymizer with Railway

PR Postgres Anonymizer fills every Railway PR environment's Postgres with a masked copy of production. On deploy and then every night, the masker dumps production and masks emails, names, phones and addresses with PostgreSQL Anonymizer, then stores the dump in a Railway Bucket. Each new PR environment restores that dump. Reviewers see realistic data without any real customer records.

## About Hosting PR Postgres Anonymizer

Railway PR environments copy your services, but their databases start empty. Copying production in is not safe, because preview URLs are public. This template adds two small services and a bucket next to your existing Postgres. The masker runs in production, once on deploy and then daily at 03:00 UTC. It runs `pg_dump`, loads the dump into a throwaway Postgres inside its own container, applies your masking rules and uploads the masked dump. Unmasked data never leaves that container. The seeder runs once when a PR environment is created. It downloads the masked dump and restores it into that PR environment's own Postgres, then exits. Strict mode refuses to publish while any column that looks like personal data has no masking rule.

## Common Use Cases

- Preview a PR against data with production's row counts, foreign keys and edge cases.
- Reproduce "works in prod, broken in preview" bugs without handing customer data to reviewers.
- Give contractors, design reviewers or QA a live preview that contains no personal data.
- Keep demo and preview data current, since the masker makes a fresh copy every night.

## Dependencies for PR Postgres Anonymizer Hosting

- A Railway project with a Postgres service, named `Postgres` by default.
- PR environments turned on: Project Settings → Environments → Enable PR Environments.

### Deployment Dependencies

- [PostgreSQL Anonymizer](https://postgresql-anonymizer.readthedocs.io/) 3.3.2, from the official Dalibo image on Postgres 18.
- [Railway PR environments](https://docs.railway.com/environments) and [Buckets](https://docs.railway.com/storage-buckets).

### Implementation Details

```
production                                     PR environment (pr-…-42)
┌──────────┐ pg_dump  ┌─────────────────────┐   ┌────────┐ restore ┌──────────┐
│ Postgres │ ───────▶ │ masker (daily 03:00)│   │ seeder │ ──────▶ │ Postgres │
└──────────┘          │ throwaway Postgres  │   └────────┘         │ (empty)  │
                      │ + anon masking rules│       ▲              └──────────┘
                      └─────────┬───────────┘       │ download
                                ▼ upload            │
                        Bucket: pr-seed/latest.sql.gz
```

Masking rules are plain SQL in the masker's `MASKING_RULES` variable:

```sql
SECURITY LABEL FOR anon ON COLUMN public.customers.email
  IS 'MASKED WITH FUNCTION pg_catalog.concat(''customer'', id, ''@example.com'')';
SECURITY LABEL FOR anon ON COLUMN public.customers.last_name
  IS 'MASKED WITH FUNCTION anon.dummy_last_name()';
```

See [`demo/rules.sql`](https://github.com/AmitKulkarni23/pr-postgres-anonymizer/blob/main/demo/rules.sql) for a full example and the [function reference](https://postgresql-anonymizer.readthedocs.io/en/stable/masking_functions/) for everything you can use.

### Why Deploy PR Postgres Anonymizer on Railway?

The masker, the seeder and the bucket run in the same project as your app and its database. The masker reads production over Railway's private network, Railway creates and deletes the PR environments, and the dump lives in a Railway Bucket. There is no separate server, cron host or storage account to run.

## Setup

1. In the project that holds your production Postgres, add the template (canvas → Create → Template). When asked, paste your masking rules into `MASKING_RULES`, and enter `${{Postgres.DATABASE_URL}}` for the masker's `SOURCE_DATABASE_URL` and the seeder's `DATABASE_URL` (use your database service's name if it is not `Postgres`). The template brings no database of its own.
2. The masker runs as soon as it deploys. Check its logs. If strict mode lists unmasked columns, add rules for them, or list them in `ALLOW_UNMASKED` if they are not personal data. A redeploy runs it again.
3. Give the seeder production's bucket coordinates as **plain values**. Either run [`setup/link-seeder.sh`](https://github.com/AmitKulkarni23/pr-postgres-anonymizer/blob/main/setup/link-seeder.sh) from a clone of this repo that you have linked to the project with `railway link`, or copy the five values from the bucket's Credentials tab into the seeder's `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`. Do not use references and do not seal them (see below).
4. Open a PR. Its environment's Postgres is seeded within a minute.

### Why the seeder needs plain values

Every Railway environment gets its own bucket. A reference like `${{pr-seed-dumps.BUCKET}}` therefore resolves to a new, empty bucket inside a PR environment. Plain variables are copied into PR environments unchanged, so the seeder there still reads production's bucket. Sealed variables are not copied at all. The bucket only ever holds masked dumps. The trade-off: code in a PR environment can read and overwrite that bucket. Railway does not deploy PRs from people outside your workspace, which limits who could.

## Variables

`MASKING_RULES`, `SOURCE_DATABASE_URL` and `DATABASE_URL` are asked for on deploy. Every variable with a plain default below is unset, so the scripts use that default. Set one only to change it.

**masker**

| Variable | Default | Description |
|---|---|---|
| `SOURCE_DATABASE_URL` | required, e.g. `${{Postgres.DATABASE_URL}}` | Production database to dump. Read over the private network. |
| `MASKING_RULES` | required | `SECURITY LABEL FOR anon` rules, one per column (multiline SQL). |
| `ALLOW_UNMASKED` | empty | `schema.table.column` list that detection flags but that is not personal data. |
| `STRICT` | `true` | `false` publishes anyway and logs a warning listing the unmasked columns. |
| `MASK_AT_UTC` | `03:00` | Daily run time, `HH:MM` UTC. The masker also runs on every deploy. |
| `SOURCE_ENVIRONMENT` | `production` | The only environment the masker runs in. Elsewhere it exits at once. |
| `S3_*` | bucket references | Bucket the masked dump is uploaded to. |

**seeder**

| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | required, e.g. `${{Postgres.DATABASE_URL}}` | This environment's own database, the one to fill. |
| `S3_*` | set at setup | Production's bucket as plain values (setup step 3). |
| `SEED_ENVIRONMENT_REGEX` | `(^\|-)pr-([a-z0-9]+-)?[0-9]+$` | Environments the seeder may overwrite. Matches Railway's PR environment names only. |
| `SOURCE_ENVIRONMENT` | `production` | Never seeded, whatever the regex says. |

## Safety guarantees

- The seeder never runs in `SOURCE_ENVIRONMENT`, or in any environment whose name doesn't look like a PR environment (`staging` and `pr-preview` are skipped).
- A PR environment is seeded once. Redeploys keep whatever reviewers created. The marker is a comment on the database, so it doesn't appear in your schema.
- The restore runs in one transaction. If it fails, the database is left as it was.
- Strict mode is on by default. A bad rule, a missing extension or an unmasked column stops the run, and the previous dump stays in place.

## Limits

- Postgres only.
- The whole database is copied: no subsetting yet. The masker's container needs room for about two copies of your data.
- Extensions your production schema uses must exist in the Anonymizer image (Postgres 18 contrib). Otherwise the load fails loudly. pgvector is not included.
- Detection uses a dictionary of column names (`email`, `phone`, `last_name`…). It cannot see personal data in free text, JSON or oddly named columns. Review your schema, and mask free text whole.
- If your app runs migrations before the seeder finishes, the restore replaces them with production's schema. Redeploy the app after seeding to apply the PR's migrations on top.
- With Focused PR Environments, the seeder deploys only if it is a dependency of a changed service. Reference it from your app, or deploy it from the canvas.

## Development

```bash
bun install
bun run typecheck          # .railway/railway.ts
bash tests/e2e.sh          # Docker: masks a demo database and proves nothing leaks
railway config plan        # then railway config apply
```

Architecture, decisions and failure modes are in [`docs/system-specs/`](https://github.com/AmitKulkarni23/pr-postgres-anonymizer/tree/main/docs/system-specs).


## Similar templates

- [RustFS | (Just Updated) S3 Object Store, Public URL and Keys Set](https://railway.com/deploy/rustfs-or-just-updated-s3-object-store-p) — S3-compatible object store, keys and public URL preset, volume kept
- [TimescaleDB](https://railway.com/deploy/timescaledb-railway-template) — A PostgreSQL-based time-series database for analytics on Railway.
- [Neo4j 2026.10 Community](https://railway.com/deploy/neo4j-202610-community) — Neo4j graph database with APOC, Browser, Bolt TCP proxy and a volume

Open this page in a browser: https://railway.com/deploy/pr-postgres-anonymizer
