Skip to Content
GuidesExport to Postgres

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 psql on 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 postgres
Exporting… preparing archiving 11 tables · 248 records · 67 files · 34 relationship edges Bundle written to foir-export-2026-07-14.tar.gz

If 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

OptionWhat it does
-o, --out <path>Write the archive somewhere specific.
--target <dsn>Apply the bundle directly into this database, from your machine. Needs psql.
--postgisExport location fields as PostGIS geography(Point,4326) instead of JSON. Your database needs the postgis extension.
--include-rawAlso 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 across

Each 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.sh

The 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 see

With 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.sh

Two 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

Last updated on