Project files

Declare functions, collections, roles and auth providers as files, so the sandbox and production build from the same source.

Docs menu

A Substrio project is a directory. substrio init makes the first files; everything else is optional. The same files drive substrio push and substrio deploy.

myapp/
  substrio.toml             project settings (never a key)
  functions/                one .js file per function
  collections/              one JSON file per collection: schema, rules, indexes
  public/                   static assets

substrio.toml

A key may sit at the top of the file or under a [project] table; a key in [project] wins. substrio.toml is meant to be committed, so it never holds a key: the key lives in a profile saved by substrio login.

[project]
url = "https://myapp.substrio.com"
tenant = "myapp"
profile = "myapp"

functions_dir = "functions"
collections_dir = "collections"
rules_file = "functions/rules.json"
mcp_file = "mcp.json"
plans_file = "plans.json"
dashboard_file = "dashboard.json"

[functions.api]
routes = ["ANY /api/*", "POST /emails"]
main = "handler"

[functions.api.vars]
API_BASE_URL = "https://api.example.com"

[[build.steps]]
dir = "web"
run = "npm ci && npm run build"

[site]
ignore = [".DS_Store"]
precompress = { brotli = true, gzip = true, min_bytes = 1024 }

[[site.parts]]
from = "web/dist"
to = "/"

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

[[triggers]]
type = "insert"
collection = "users"
function = "onUser"

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

Connection keys

Key Type Default Meaning
url string none Server URL. Overridden by SUBSTRIO_URL.
tenant string default The app's slug. Overridden by SUBSTRIO_TENANT or --tenant.
profile string the profiles file's default Which saved profile (URL, key, tenant) to use. Overridden by SUBSTRIO_PROFILE or --profile.

Order of precedence, last wins: the profile, then substrio.toml, then the environment variable SUBSTRIO_URL or SUBSTRIO_TENANT.

Where files are

Key Type Default Meaning
functions_dir string functions Directory of function files. Every .js file in it is a function named after the file. Environment: SUBSTRIO_FUNCTIONS_DIR.
collections_dir string collections Directory of collection files (below).
public_dir string public Static assets substrio deploy syncs. Environment: SUBSTRIO_PUBLIC_DIR.
rules_file string none JSON file of the app's security rules (the body security.rules.set takes).
mcp_file string none JSON file of the app's MCP tools (mcp.md).
plans_file string none JSON file of the app's plans, counters and feature flags.
dashboard_file string none JSON file of the app's console dashboard: its pages and panels, each panel reading one of the version's routes (dashboard.md). substrio push carries it in the version; at most 64 KB.
site_ignore string OS metadata files Comma-separated file names left out of the site. Prefer [site] ignore.

A file named here that does not exist, or is not valid JSON, stops the command before anything is sent.

[functions.<name>]

One table per function, to declare what the source file cannot. The function's file functions/<name>.js must exist, or the deploy fails.

Key Type Meaning
routes array of strings HTTP routes the function serves, as "METHOD /path[ GATE]", for example "GET /hello" or "ANY /api/*". GATE is public (the default, absent) or auth, which refuses a caller with no session natively, with 401, before the function runs. Each route's position in this array reaches the function as request.route (0, 1, 2, ...), so a handler table can dispatch with handlers[request.route](request).
routes_file string A JSON file, relative to substrio.toml, holding an array of route specs (the same strings routes takes). Read once per deploy; the manifest's routes becomes that array, so a large handler table and its declared routes come from one file instead of two. routes and routes_file together is an error.
route_prefix string One path every route of the function is served under, for example "/api/v1" (a leading slash, no trailing slash): GET /users/me is served as GET /api/v1/users/me, and request.route is unchanged. Applied by the CLI once per route when it builds the request, so it costs nothing per request. Needs routes or routes_file.
main string Entry point name.
vars table of strings The function's environment ([functions.<name>.vars]). Values are kept as text.

routes_file lets a function's handler table and its declared routes share one file. A function shipped in a version imports the version's other files by relative path with no bundler (import routes from "./routes.json" with { type: "json" }; see cloud-functions.md); a function deployed outside a version cannot import a local file (IMPORT_NEEDS_VERSION) unless the CLI bundles it (esbuild installed).

A route is checked against every other enabled function of the app, not just its own function's list, so the same route declared by two functions is a deploy error naming both, never a silent first-wins.

A declared route wins over the app's site on every path, / included: "GET /" answers the app's root, ahead of public/index.html. A path no function declares is the site's (its file, a clean URL, the SPA document or 404.html), so an app with a site and no "GET /" route serves public/index.html at /.

[functions.home]
routes = ["GET /"]   # https://<app host>/ is this function's answer
// routes.json
["GET /a", "GET /b auth", "GET /c"]
[functions.api]
routes_file = "routes.json"
// functions/api.js, bundled: esbuild inlines routes.json into the upload
const routes = require('../routes.json');
const handlers = [getA, getB, getC];
module.exports = (request) => handlers[request.route](request);

Table dispatch, one array load, no path work:

// routes = ["GET /a", "GET /b auth", "GET /c"]
const handlers = [getA, getB, getC];
module.exports = (request) => handlers[request.route](request);

[[build.steps]]

Commands to run before a push, in order, by sh. A failing step stops the push. [build] with steps = [...] and [[build]] tables also work.

Key Type Default Meaning
dir string . Directory to run in.
run string required The command.

substrio push runs the steps before it uploads; --no-build skips them.

[site] and [[site.parts]]

The static site substrio serves for the app. substrio push merges the parts in order; a later part's file replaces an earlier file at the same path. [site] needs at least one [[site.parts]].

Key Type Default Meaning
[site] ignore array of strings OS metadata files File names left out.
[site] precompress true, or table off Write .br and .gz beside compressible files. A table overrides: brotli (true), gzip (true), min_bytes (1024), min_saving_percent (10), types (.html, .css, .js, .mjs, .json, .svg, .xml, .txt, .map, .webmanifest). Put it before the first [[site.parts]].
[[site.parts]] from string required Directory to publish. It must exist (did the build run?).
[[site.parts]] to string / Where it mounts in the site. No . or .. segments.

[[cron]] and [[triggers]]

Table Keys (all strings, all required) Meaning
[[cron]] function, expression Run the function on a cron expression such as 0 3 * * *. substrio.toml has no time zone key, so it runs in UTC (schedules.md).
[[triggers]] type, collection, function Run the function when a document event of that type happens in the collection (for example insert).

[auth]

Sign-in settings of the app, carried by substrio push and applied when the version goes live. The keys are the ones the auth.settings.set operation takes (substrio op auth.settings.set --help lists them): for example email_function and reset_url. An unknown key stops the push before anything is sent. A value that differs between environments (the site of a development deploy and of the live one) is written as <key>_env = "VARIABLE", the name of the environment variable that holds it, in place of the value (never both): reset_url_env = "APP_RESET_URL" sends reset_url. A variable that is not set stops the push. What the function named by email_function receives is in authentication.md.

[roles.NAME]

Roles of the app, one table each, applied by substrio deploy:

[roles.staff]
description = "Venue staff"
inherits = ["editor"]
grants = ["data.read"]       # optional
denies = ["data.delete"]     # optional
Key Type Meaning
description string Shown by role.list.
inherits string, or an array of one string The role this one builds on (a built-in such as viewer or editor, or another role). A role inherits one role: a longer array is refused.
grants, denies arrays of strings Operations the role grants or denies, as role.create takes them.

Applying is idempotent: a role that is not there is created (role.create), one that is there is updated by name (role.update). Only the keys you write are sent, so a key you leave out keeps its value. A role the file does not name is left alone, and so are the built-in roles. A name that is a built-in role is refused by the server.

[auth.providers.NAME]

Social sign-in providers (Google, GitHub, any auth.provider.update provider), one table each under [auth], applied by substrio deploy:

[auth.providers.google]
client_id = "1234.apps.googleusercontent.com"
client_secret_env = "GOOGLE_CLIENT_SECRET"   # the NAME of an environment variable
redirect_uri = "https://example.com/auth/google/callback"
scopes = "openid email profile"
enabled = true

[auth.providers.facebook]                    # Meta's sign in app; the platform redeems its code
client_id_env = "FACEBOOK_APP_ID"
client_secret_env = "FACEBOOK_APP_SECRET"
redirect_uri_env = "FACEBOOK_CALLBACK"
optional = true                              # not set here: named, and the deploy goes on
Key Type Meaning
client_id string, required The provider's client id (or client_id_env).
client_secret_env string The name of the environment variable that holds the client secret, read when the file is applied. The secret is never written in the file: client_secret is refused, and a variable that is not set stops the apply before anything is sent. A provider with no client_secret_env keeps the secret the server already holds.
redirect_uri, scopes, provider_url string As auth.provider.update takes them.
<key>_env string For client_id, redirect_uri, scopes and provider_url: the name of the environment variable that holds the value, read when the file is applied. A key and its _env form are never both written.
optional boolean A variable the table names that is not set is reported (auth provider NAME: VAR not set) instead of stopping the apply: the rest is sent and the server keeps what it holds for the missing values; with no client id at all the table is not sent this time.
enabled boolean Turn the provider on or off.

facebook (or meta) signs in with a code from Meta's login dialog: the platform redeems it at Graph with the app's id, secret and redirect_uri, reads the person with appsecret_proof, and takes Meta's address as verified (Meta answers only the account's confirmed primary address). A person who unticked the address in the dialog is refused with no_email: "declined" (send them to the dialog again with auth_type=rerequest); an account with no address with no_email: "missing".

The secret travels to the server and appears in no output, error or log of the CLI. substrio op auth.provider.get provider=google shows has_secret: true, never the value. [auth.providers.NAME] tables are not auth settings: they do not count as unknown [auth] keys.

substrio push does not carry roles or providers: they are applied to the server your profile names by substrio deploy.

Collection files: collections/<name>.json

One file per collection; the file name (without .json) is the collection name. Every key is optional; together they are everything the server takes for a collection (kind space, a unique index spanning more than one collection, is not one collection's and belongs in a project-level file, which does not exist yet):

{
  "schema": {
    "strict": false,
    "rules": [
      { "field": "title", "type": "string", "required": true, "max": 200 },
      { "field": "_event_oid", "type": "number", "required": true },
      { "field": "created_at", "type": "datetime" },
      { "field": "status", "type": "string", "enum": ["draft", "live"] }
    ]
  },
  "rules": { "read": "allow", "write": "role:editor", "create": "auth", "delete": "role:admin" },
  "indexes": [
    "status",
    { "field": "email", "unique": true },
    { "field": "title", "keyword": true, "keyword_term_bytes": 32 },
    { "field": "tags", "elements": ["sku"] },
    { "compound": ["tenant", "created_at"] },
    { "fields": ["org", "email"], "unique": true, "nulls": "skip", "within": "collection" }
  ],
  "expire": { "field": "deleted_at", "after_ms": 2592000000, "existing": "allow" },
  "aggregates": [
    { "name": "byRegionDay", "group": ["region", "day"], "metrics": { "n": "count", "total": { "sum": "amount" } }, "where": "status = \"live\"" }
  ]
}
Key Type Meaning
schema object The body of schema.update (below).
rules object This collection's entry of the security rules (below).
indexes array Fields, keyword fields, element tuples, compound indexes and compound unique sets to keep built (below).
expire object One TTL declaration for this collection: { field, after_ms, existing? } (below).
aggregates array Live, pre-grouped counts and sums this collection keeps up to date (below).

schema

The body of schema.update (substrio op schema.update --help).

  • rules is a list of field rules. A field is a dotted path such as address.city. A rule has a type (string, number, boolean, array, object or datetime) and optionally required, min, max (a number's value, or a string's length), pattern (a regular expression, for strings) and enum (the allowed strings). Spell type exactly: any other word means any type.
  • null counts as absent. A field that is not required accepts null; a required field refuses it unless its rule says "nullable": true.
  • strict defaults to true: a document with a field no rule names is refused. Set "strict": false for wide or evolving documents. The stored schema, from schema.get, always shows the value in force.
  • datetime accepts an RFC 3339 string ("2026-10-07T00:00:00Z") or epoch milliseconds (1790799172221).
  • A field name that starts with _ is kept as you write it (_event_oid stays _event_oid). Only _id, _createdAt, _updatedAt and _version are substrio's own.

rules

Who may do what in this collection: read, write, create, delete, each one of allow, auth, owner, deny or role:<name> (permissions.md). It is the same as one entry of the security.rules.set list, without the collection name.

A collection's rules may live in one place only: its collections/<name>.json, or the rules_file (for example functions/rules.json). Rules for the same collection in both is an error, and the command stops without changing anything.

indexes

A list of the fields (and field sets) to index in this collection. An entry is a plain field name ("status", or a dotted path such as "address.city"), or one of these object forms, each the server's own field names, nothing translated into a second vocabulary:

Entry Server declaration (index.create) Meaning
"status" or { "field": "status" } kind collection, field Keep the field's keys built.
{ "field": "email", "unique": true } kind collection, field, unique Also refuse a second document holding the same value.
{ "field": "title", "keyword": true, "keyword_term_bytes"?: 32 } kind collection, field, keyword, keyword_term_bytes? Tokenize the field for keyword search.
{ "field": "tags", "elements": ["sku"] } kind collection, field, elements Index the named members of each array element.
{ "compound": ["tenant", "created_at"] } kind collection, compound One index over the lead fields, ordered by the last; order matters, ["a","b"] and ["b","a"] are two distinct declarations.
{ "fields": ["org", "email"], "unique": true, "nulls"?: "skip"|"count", "within"?: "collection"|"document"|"both" } kind collection, fields, unique: true, nulls?, within? A unique set of fields; this one is order-independent.

A path has no empty segment: "a..b", ".a" and "a." are refused by the CLI before anything is sent.

  • An index keeps the field's keys built and up to date for as long as it is declared: a filter or a sort on it never waits for a first build. The keys are built when the file is applied, so a migration has them before traffic arrives.
  • "unique": true also makes every write path (insert, put, update, patch, import, a function's db) refuse a second document (or field set) holding the same value with UNIQUE (409), whose field names the declaration that refused (a compound: its members, , apart, with fields and values). A missing field, null and "" never conflict.
  • If matching documents already hold the same value, the unique declaration is refused with INDEX_DUPLICATES (409), naming the first document and how many there are, and nothing is declared. GET /v1/_indexes?kind=conflicts&collection=NAME&field=F lists them, a page at a time (limit, cursor, next_cursor). Remove or change them, then apply the file again.
  • Applying is idempotent. substrio deploy sends each entry as index.create (if_not_exists); the answer comes after the keys are built. index.list shows a field's state, and index.drop takes a declaration away (the file's entry is declared again at the next apply).

expire

One TTL declaration per collection: { "field": "deleted_at", "after_ms": 2592000000, "existing"?: "refuse" | "allow" }. field holds a datetime; a document is removed after_ms milliseconds past it. existing says what happens to documents already past that point when the declaration is first applied: "refuse" (the default) stops the apply naming them, "allow" lets them be removed. Sent as one index.create of kind expiry, after the indexes, with if_not_exists (declaring the same collection again is a no-op, not a conflict).

aggregates

Live, pre-grouped counts and sums the server keeps up to date as documents change: { "name": "byRegionDay", "group"?: ["region", "day"], "metrics": { "n": "count", "total": { "sum": "amount" } }, "where"?: "status = \"live\"" }. group is the fields to group by (omit it, or use [], for one running total); metrics names at least one of "count" or { "sum": "field" }; where is a query-language string narrowing which documents count. Each entry is one index.create of kind aggregate, sent after expire, in file order; it builds its groups before it answers and is idempotent for the same name and shape on its own (a different shape under the same name is refused, there is no if_not_exists for it).

When the files are applied

  • substrio deploy applies them to the app your profile names.
  • substrio push carries the schema, rules, indexes and aggregates in the version, so a sandbox and, after substrio promote, production can see what is declared; it warns that it does not itself declare (apply) the indexes, expiry or aggregates, only substrio deploy does.

Applying a file twice changes nothing: every declaration is sent with if_not_exists, or is idempotent on its own shape (aggregates), so a second apply reports "up to date" and sends no new writes.

At every apply, substrio deploy also compares what the app has declared for each collection (index.list of kind collection, aggregate and expiry) against the file. An index, aggregate or expiry the server has but the file no longer names is drift: reported, one line per kind and name, and left in place. Pass --prune to drop it instead, with index.drop. A field's undeclared adaptive key is never reported or pruned: index.list only ever answers a declaration that was explicitly made.

A file that is not valid JSON, or names an unknown key, is refused with the file name, the key, and (for indexes, expire or aggregates) the entry index and what was expected; the server keeps what it had.

Environment variables

Variable Meaning
SUBSTRIO_URL, SUBSTRIO_TENANT Server and app; override substrio.toml and the profile.
SUBSTRIO_PROFILE Which saved profile to use.
SUBSTRIO_FUNCTIONS_DIR, SUBSTRIO_PUBLIC_DIR Override functions_dir and public_dir.
SUBSTRIO_CONFIG_DIR Where the profiles file lives (default ~/.substrio).

Profiles are saved in ~/.substrio/config.toml (owner-only) by substrio login.