Export to Postgres
Take your project out of Foir as an ordinary Postgres database — typed tables, real foreign keys, your media, and the access rules that protected it.
This guide runs the export, restores it, verifies it, and is honest about what doesn’t come with it. If you’d rather read why this exists, see Your Data Is Portable.
Before you start
- You need to be the owner of the workspace. Exporting a project reads all of its content at once, so it isn’t something an editor can do.
- You need
psqlon your machine if you want the CLI to restore for you. - You need somewhere to restore to: Postgres 15 or newer, or a Supabase project.
Run it
The simplest form writes an archive to the current directory:
foir export postgresExporting…
preparing
archiving
11 tables · 248 records · 67 files · 34 relationship edges
Bundle written to foir-export-2026-07-14.tar.gzIf you’d rather it went straight into a database you already have, give it a connection string. The bundle is applied from your machine — the connection string never reaches Foir:
foir export postgres --target "postgresql://user:pass@localhost:5432/myproject"That restores the schema, loads the data, applies the foreign keys, and then checks the result against what we sent. It’ll tell you if anything moved.
Options
| Option | What it does |
|---|---|
-o, --out <path> | Write the archive somewhere specific. |
--target <dsn> | Apply the bundle directly into this database, from your machine. Needs psql. |
--postgis | Export location fields as PostGIS geography(Point,4326) instead of JSON. Your database needs the postgis extension. |
--include-raw | Also include a full-fidelity JSON mirror of every record — drafts, versions and variants included. See Drafts and versions. |
What’s in the bundle
schema.sql One typed table per model
data/*.csv Your content
constraints.sql The foreign keys
policies.sql Your access model, as row-level security
validate.sql A check you run against your restored database
validation.json What we measured, in machine-readable form
assets/manifest.json Your media: a download link per file
assets/fetch.sh …and a script that fetches them
auth/customers.jsonl Your customers and how they sign in
auth/providers.jsonl Your auth providers, secrets removed
README.md How to restore it, and what didn't come acrossEach of your models becomes a table. A product model with title, price and a reference to a category becomes:
CREATE TABLE product (
id text PRIMARY KEY,
natural_key text UNIQUE,
title text,
price numeric,
category_id text, -- a real foreign key into category
owner_kind text,
owner_id text,
access_mode text NOT NULL DEFAULT 'private',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);Query it with plain SQL. That’s the entire point.
Restore it yourself
If you exported an archive rather than using --target, unpack it and apply the pieces in this order:
tar xzf foir-export-2026-07-14.tar.gz
createdb myproject
# 1. The tables
psql myproject -f schema.sql
# 2. The data
for f in data/*.csv; do
table=$(basename "$f" .csv)
psql myproject -c "\copy $table FROM '$f' WITH (FORMAT csv, HEADER true)"
done
# 3. The foreign keys — AFTER the data
psql myproject -f constraints.sql
# 4. Check what landed against what we sent
psql myproject -v ON_ERROR_STOP=1 -f validate.sql
# 5. Your access rules
psql myproject -f policies.sql
# 6. Your media
bash assets/fetch.shThe order isn’t a style preference. A foreign key is checked on insert, so the constraints have to go on after the rows — a model that references itself (a page whose parent is a page) has no load order that would satisfy them up front. And a policy applied to a half-loaded table would hide the rest of it.
Verify it
validate.sql is the export checking its own work, on your database.
It recomputes every row count and every column checksum that we measured on our side, and compares. Silence is a pass. If something moved, it names the table and the column:
ERROR: the restored database does not match what Foir exported:
product.price (digest): expected 4b1c…, found 9ae2…Run it. It costs a second, and it’s the difference between “the restore finished” and “the restore is correct”.
Two things worth knowing about what it proves. The row counts are checked twice — once on our side against an independently derived count of what should be there (if those two disagree, the export refuses to run at all), and once against your database. The checksums prove your copy is byte-for-byte the copy we sent: nothing mangled by CSV quoting, truncated by a half-finished load, or rounded in transit.
Your access rules
policies.sql recreates your access model as Postgres row-level security. After you apply it, a customer sees a row when they own it, when it’s public, when they’ve been explicitly granted it, or when it hangs off something they can already see — the same rules Foir applied, now enforced by your database.
Tell the database who’s asking by setting one session variable:
SET request.jwt.claims = '{"customer_id": "cus_abc123"}';
SELECT * FROM product; -- exactly the products that customer could seeWith no claims set, the caller is anonymous and sees exactly the public rows.
On Supabase or PostgREST
Nothing to do. Both already set request.jwt.claims from the bearer token, and the bundle creates the anon, authenticated and service_role roles they expect. Restore into your Supabase project and your access model is live.
One thing to watch when you’re testing: the role you restore as owns the tables, and table owners bypass row-level security. So if you test your policies as the superuser, every one of them will appear to pass. Test as authenticated:
SET LOCAL ROLE authenticated;
SET request.jwt.claims = '{"customer_id": "cus_abc123"}';
SELECT * FROM product;Your media
The bundle doesn’t contain your files — it contains a manifest of time-limited download links, one per file your content actually references, and a script that fetches them to the paths the exported content points at:
bash assets/fetch.shTwo consequences worth being clear about. The links expire (see expiresAt in manifest.json) — if they do, re-export; nothing has moved. And while they’re valid, anyone holding that manifest can download those objects: treat it as a secret.
Your customers
The bundle carries your customers and how they sign in, so their accounts come with them:
auth/customers.jsonl— one customer per line: who they are, and their linked OIDC providers (Google, Apple, Shopify, …) so social sign-in keeps working.auth/providers.jsonl— your auth-provider configuration, with every secret stripped out (a provider’s OAuth client secret is yours to re-enter on the new system, not something we hand around).
Passwords are opt-in, and gated, because a bundle full of password hashes is the most sensitive thing this command can produce:
foir export postgres --include-credentials --target "$DSN"It asks for your password before it runs, records the export in your audit log, and then includes each customer’s password hash — pulled from our identity provider as a portable argon2id string, the format Supabase (GoTrue), another Kratos, or any standard verifier accepts. Nobody has to reset their password. A customer who signs in only through a provider has no password, and simply doesn’t get one.
What is never in the bundle: a customer’s password-reset token (a live secret), and your providers’ client secrets.
What doesn’t transfer
Stated plainly, because finding out later is worse.
- The GraphQL API and the SDK. Foir generates them at runtime from your models. Point PostgREST or pg_graphql at the restored database, or write your own layer.
- Per-field permissions and API scopes. This is the important one. Foir enforced row visibility in the database — and that came with you. But which fields of a row a caller could read, and which operations a customer’s role permitted, were enforced in Foir’s API layer. Row-level security can’t express either. A customer who can see a row in your restored database can see every column of it. If you relied on a field being hidden, that’s now your application’s job — or a column
GRANT, or a view. - Sessions and signing keys. Everyone signs in once, against your new system.
- Image transforms. You get the original objects, not the resized renditions the CDN was serving. Private-file gating doesn’t transfer either.
- Scheduled publishing, lifecycle hooks, realtime collaboration, search. These are things Foir does, not data it stores. Their definitions live in your
foir.config.ts.
Drafts and versions
The export is the published channel — what your live site or app would serve. Drafts, older versions, and unpublished variants aren’t in the typed tables, because there’s no idiomatic relational shape for them.
If you want everything, add --include-raw. You’ll get an extra table holding every record exactly as Foir stored it — drafts, versions and variants included — as JSON. It’s the un-relational thing this export exists to undo, so it’s opt-in.
Deleting instead of exporting
If what you need is erasure rather than a copy — a customer exercising their right to be forgotten, say — that’s a different surface, and it’s covered in Your Data Is Portable.
See also
- Your Data Is Portable — the commitment, and the deletion surface
- Authorization & Access — the access model that travels with your data
- CLI Reference — every flag