Bring your project

Move an existing backend to substrio in order: data, sign-in, routes, jobs, mail, files, secrets, tests, deploy.

Docs menu

Follow these steps in order. Each step has the command, what you should see, and the mistake people make most. It works for a developer at a terminal and for an AI agent running the same commands (add --json to any command for machine-readable output).

You need the server and the CLI first: getting-started.md.

1. Inventory your backend

Write down what the old backend has, and where each part goes on substrio:

In your backend On substrio Step
Tables, collections, documents Collections (collections/<name>.json) 3, 4
Existing rows substrio data import 4
Primary keys, unique columns _id and a field for the natural key 5
Users, passwords, sessions, OAuth substrio auth, the identity app 6
HTTP routes, controllers Functions with routes 7
Cron jobs, workers, queues [[cron]], queues 8
Outgoing email An email function, a mail provider 9
Uploaded files, static site substrio files, public/, [site] 10
Environment variables, API keys Secrets (substrio env push) 11
Tests Calls to the dev sandbox host 12
Deploy scripts substrio push, substrio promote 13

Common mistake: starting with code. Start with the data and the ids (steps 3 to 5); the routes follow from them.

2. Create the project and run it

mkdir myapp && cd myapp
substrio init --tenant myapp --yes
substrio push

substrio push sends the project to your app's dev sandbox and prints its host, https://myapp--dev.substrio.com: a copy of the app's data that runs what you pushed, while the live app stays as it is. Run substrio push again after each change below.

Common mistake: running the later commands from another directory. The CLI reads substrio.toml in the current directory to find the app.

3. Model your collections

Make one file per collection in collections/. The file name is the collection name:

mkdir collections
cat > collections/orders.json <<'JSON'
{
  "schema": {
    "strict": false,
    "rules": [
      { "field": "event_id", "type": "string", "required": true },
      { "field": "total", "type": "number", "required": true },
      { "field": "created_at", "type": "datetime" }
    ]
  },
  "rules": { "read": "auth", "write": "role:admin", "create": "auth", "delete": "role:admin" },
  "indexes": ["event_id"]
}
JSON

Run substrio push; you see it print the new version. Check:

substrio collections list
substrio collections schema orders

What to know:

  • A schema's "strict" is true by default: a field no rule names is refused. For a legacy document with many fields, list the ones you want checked and set "strict": false. substrio collections schema NAME always shows the value in force.
  • datetime accepts an RFC 3339 string ("2026-10-07T00:00:00Z") or epoch milliseconds. Your old ISO strings work as they are.
  • Field names that start with _ are kept as written. Only _id, _createdAt, _updatedAt and _version belong to substrio.
  • A collection's access rules go either in its file or in the rules_file, never both.

Every key of the file: project-files.md.

Common mistake: a schema rule that no legacy document satisfies (a number kept as a string in the old data). Fix the type in the rule, or convert the field while exporting (step 4).

Relational data you would rather keep relational: substrio also runs SQL (sql-over-api.md). The rest of this guide uses collections.

4. Import your data

Export each table or collection from the old store to one of four shapes:

  • NDJSON: one JSON document per line (users.ndjson).
  • A JSON array: [ {...}, {...} ] (users.json).
  • An object: {"documents": [ {...}, {...} ]}.
  • CSV (--format csv, or a .csv file): every column is a string unless --types says otherwise (--types age:number,born:date,active:boolean,meta:json; an unlisted column stays a string). An empty field is left out of the document unless --empty-as null. A value that does not fit its type refuses that row alone, with the line and the rule it broke.

Examples of exporting:

sqlite3 -json app.db "select * from users" > users.json
psql -At -c "select json_agg(t) from users t" mydb > users.json
mongoexport --db mydb --collection users --out users.ndjson

Then import:

substrio data import users --file users.ndjson
substrio data import users --file users.json
substrio data import users --file users.csv --types age:number,active:boolean
cat users.ndjson | substrio data import users --file - --format ndjson   # stdin

The format follows the file extension (.ndjson, .json or .csv); --format overrides it and is required for stdin. The file is streamed start to finish, never loaded whole, so a dump of any size imports in bounded memory; it is cut into batches (--batch-bytes, default 4 MiB; --batch-docs) sent several at a time (--concurrency, default 4; --concurrency 1 restores a single connection's strict order).

A document that cannot be stored is refused alone and the others are written. Under --json each refusal is its own NDJSON line as it happens: {"event": "violation", "position": 3, "code": "KEY_EXISTS", "message": "..."} (a schema or CSV-type refusal also carries violations: [{field, message}]); progress lines ({"event": "progress", "imported", "failed", "offset", "docs_per_s"}) follow at most once a second, a {"event": "retry", "status", "attempt", "wait_ms"} line precedes every wait for a 429 or 503 (the server's own Retry-After is honoured when it sends one, so a long wait is never a silent hang), and the run ends in exactly one done or stopped line. Without --json, the same is printed as text, the refusals listed as they happen. The command exits with code 8 (partial import) when any document was refused (0 with --allow-failures) and 0 when all were stored; a run that fails as a whole exits with the failure's own code (see substrio help) and names the first document not written. Fix the refused documents and import only them. (The server's own answer to data.import is {inserted, skipped, replaced, rejected, errors: [{index, code, message, violations?}], total}; documents travel as their own bytes, one call per batch, never one call per document.)

A run that stops partway (the connection drops, the process is killed) can be continued: --checkpoint FILE (default FILE.substrio-import, beside the input) tracks how far the input was read and stored; --resume picks up from there after checking the file has not changed size or modified time. Resuming needs every document to land at most once even if a batch's answer was lost right after it was written, so --resume always needs an idempotent write and is refused otherwise, even at the default concurrency: --upsert (on_conflict: replace, a batch at a time: a document whose _id is already stored is replaced, not refused, and counted as replaced) or --on-conflict skip (on_conflict: skip: a document whose _id is already stored is skipped, not refused, and counted as skipped). Without one of those, no checkpoint file is written at all, --resume or not: there is nothing safe to resume from.

To check, and to copy data between servers, use count and export:

substrio data count users
substrio data count users where.role=admin       # the filters of `substrio data query`
substrio data export users --json > users-copy.json

What to know:

  • A document that carries _id is stored under it (a base62 string or a positive whole number), so the references between your documents still resolve. A document without _id gets a generated one.
  • An _id already in use is refused with KEY_EXISTS; nothing is overwritten, unless --upsert (replaced) or --on-conflict skip (left as it was) is given. An _id of any other shape is refused with INVALID_ID.
  • _createdAt, _updatedAt and _version in your file are ignored: an imported document is stamped anew. Keep your old creation time in an ordinary field such as created_at.
  • substrio data insert users --data '{"email":"[email protected]"}' inserts one document: the --data value is the document itself, with no wrapper. It refuses a document that names _id (ID_RESERVED); to choose ids, import.

Common mistake: Mongo's mongoexport writes ids as {"$oid": "..."} and dates as {"$date": "..."}. Convert them to plain strings and numbers before importing.

To load the same data into production, run the same command with the production profile: substrio data import users --file users.ndjson --profile prod.

5. Ids and natural keys

substrio assigns each document an _id (a base62 string such as Qs7HRQnMTg) and reads it back as a string, even when you imported a number.

If your old table is keyed by an email, a slug or a UUID:

  1. Keep it as an ordinary field ("email": "[email protected]").
  2. Look documents up by that field: substrio data query users --data '{"where":{"email":"[email protected]"}}'.
  3. List the field in indexes of the collection file, so lookups stay fast.
  4. If other documents point at it, store the natural key in them too, or the _id you imported.

Uniqueness constraints are not declarable yet. A schema or collection file cannot say "this field is unique"; it is coming. Until then, make the code that writes the field check first, or declare it inside a function with db.collection("users").unique("email") (runtime/collections-and-chains.md, "Indexes"). After an import, verify the field by hand: query for duplicates before you rely on it.

Common mistake: assuming data.insert takes an _id you choose. It does not; import does, and a function can choose with put(doc).

6. Sign-in

substrio has users, passwords, sessions and roles built in (authentication.md):

curl -X POST $SUBSTRIO_URL/v1/_auth/register -H "X-Substrio-Tenant: myapp" \
  -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"..."}'
curl -X POST $SUBSTRIO_URL/v1/_auth/login -H "X-Substrio-Tenant: myapp" \
  -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"..."}'
# {"token":"eyJ...","user":{"id":"1","email":"[email protected]"}}
  • Your users: create them with the user.create operation (email, password, optional role): substrio op user.create --file user.json. The documentation does not describe importing the password hashes of another system, so plan for your users to set a new password with the reset email (step 9).
  • Roles: substrio op role.assign id=42 role=editor. Rules in collections/<name>.json use them (role:editor).
  • One login across your apps, and social providers: the identity app signs people in once and each app registers as its client (authentication.md, "One login across apps").
  • A function sees the signed-in user as request.auth.
  • Sign-in emails and link URLs are the [auth] table of substrio.toml (project-files.md).
  • OAuth for AI clients is built in: mcp.md.

Common mistake: giving a client your app key. A user's token goes in Authorization: Bearer <token>; the app key is for your own backend and deploys, never for a client.

7. Routes as functions

Each route of your old backend becomes a function in functions/. One file per function, no imports (bundle your dependencies into the file with a [[build.steps]] entry, for example esbuild):

// functions/api.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === '/api/orders') {
      return Response.json(db.collection('orders').find({}, { limit: 20 }));
    }
    return new Response('not found', { status: 404 });
  },
};
# substrio.toml
[functions.api]
routes = ["ANY /api/*"]

Save both files and run substrio push; then:

curl https://myapp--dev.substrio.com/api/orders
substrio logs --follow

Everything a function can call: runtime/index.md, runtime/collections-and-chains.md. To expose a route to AI agents as a tool, see mcp.md.

Common mistake: import of a package. The loader has no resolver; bundle it. Another: a source that only declares a named function is refused when invoked; export a default object, or make the file evaluate to a function.

8. Scheduled jobs and queues

A cron job is a function plus a line in substrio.toml:

[[cron]]
function = "cleanup"
expression = "0 3 * * *"

The function needs a scheduled(event, env, ctx) export or a default-export function. Cron expressions run in UTC from substrio.toml. Test a job without waiting: substrio op schedule.run id=<schedule id> (schedules.md).

Queues hold work for a consumer function, delivered at least once (runtime/queues.md). A queue is a [queues.<name>] table in substrio.toml (the fields of the manifest's queues entries); send with substrio.queue("emails").send({...}).

[queues.emails]
consumer = "sendEmail"
max_retries = 5

Triggers that run on a document change are [[triggers]] in substrio.toml.

Common mistake: a cron function that fails silently. Read the errors with substrio logs --function cleanup.

9. Mail

substrio sends the sign-in emails of your app (verification, password reset) through a function you name in [auth]:

[auth]
email_function = "send_auth_mail"
reset_url = "https://myapp.example/reset?token={token}"

functions/send_auth_mail.js calls your mail provider with fetch and the provider's key from env (step 11). For other mail, do the same from any function. The documentation lists no built-in outbound mail service: bring a provider.

Common mistake: testing mail from a sandbox. A sandbox fences outbound requests; read what it held back with substrio sandbox outbox.

10. Files and static sites

  • Static website: put it in public/ or declare [[site.parts]] in substrio.toml (project-files.md). It is served with the app.
  • Files your users upload: substrio files upload ./photo.jpg, substrio files ls, substrio files rm ID (or file.upload from a function).
  • Your old uploads folder, moved in one command: substrio files sync ./uploads images --concurrency 16 uploads every file the store does not hold at the same path and size (a second run sends only what changed). A file sent with no type of its own is stored with the type its name gives (.png is image/png).

11. Secrets

Put your old environment variables in a .env file, kept out of git, then:

printf '.env\n' >> .gitignore
substrio env push

You see one line per secret, and Pushed N secret(s). Functions read them as env.MAIL_API_KEY. A value cannot be read back, only overwritten; set a single one with substrio secrets set NAME (the value is never a command argument: it is read from stdin, --from-file PATH, or --from-env VAR).

Common mistake: substrio env pull writes .env with masked values (***). It overwrites your file. Push from your real .env, and pull only into an empty directory. substrio env push skips masked values, so it never overwrites a secret with ***.

12. Tests

Port the tests of your old backend to call your functions on the dev sandbox host and read your collections there, with the app key:

curl -s https://myapp--dev.substrio.com/api/orders -H "Authorization: Bearer $SUBSTRIO_KEY"

13. Deploy

Save a profile for the server you deploy to (the key from a file, not the command line), then:

substrio login --url https://api.myapp.example --tenant myapp --profile prod --key - < prod.key
substrio push -m "first version" --profile prod
substrio promote --profile prod

substrio push builds, uploads a new version (functions, collections, rules, cron, site) and runs it in the dev sandbox; you see its address. Try it there. substrio promote makes it live, after backing up the data. If something is wrong:

substrio rollback --profile prod
substrio versions --profile prod        # which version is live

Then import your data into production (step 4) and switch your clients over.

Common mistake: promoting without trying the sandbox. A sandbox is a copy of the app's data, with outbound requests fenced, made for that.

Versions and functions deployed on their own

substrio push and substrio promote work from versions: everything in one is pushed together, its sources content-addressed by hash. function.deploy (substrio deploy NAME, see cloud-functions.md), function.environment.set and function.delete change a function immediately, outside any version, useful for a quick fix, but the next promote risks silently overwriting it with what the version actually records, or deleting it if the version does not name it at all.

version.live.set (substrio promote, substrio rollback) checks for this before touching anything. A function deployed directly after the target's live version is recorded, that the promote would overwrite or delete with something else, refuses with 409 DIRECT_CHANGES and lists what would be lost. Nothing changes until you choose:

substrio promote 7                    # refuses if 7 would lose a direct change
substrio promote 7 --replace-direct   # apply 7 anyway; what it overwrote is reported
substrio promote 7 --keep-direct      # apply 7 except those functions, left as deployed

substrio rollback and substrio push (which runs the pushed version in a sandbox) take the same two flags. A direct change the target version already reproduces exactly, or a function deleted directly that the version would restore, is never refused: there is nothing to lose.

substrio versions lists any function deployed directly, for the app and each sandbox, without ever refusing: it is a report, not a gate.

Where to look next

  • project-files.md: every substrio.toml key, collection files.
  • agents.md, mcp.md: AI agents building and operating the app.
  • Every operation, also reachable as substrio op NAME, is in the generated API reference.
  • query-language.md: the where filter.