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).
rulesis a list of field rules. Afieldis a dotted path such asaddress.city. A rule has atype(string,number,boolean,array,objectordatetime) and optionallyrequired,min,max(a number's value, or a string's length),pattern(a regular expression, for strings) andenum(the allowed strings). Spelltypeexactly: any other word means any type.nullcounts as absent. A field that is notrequiredacceptsnull; arequiredfield refuses it unless its rule says"nullable": true.strictdefaults to true: a document with a field no rule names is refused. Set"strict": falsefor wide or evolving documents. The stored schema, fromschema.get, always shows the value in force.datetimeaccepts 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_oidstays_event_oid). Only_id,_createdAt,_updatedAtand_versionare 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": truealso makes every write path (insert, put, update, patch, import, a function'sdb) refuse a second document (or field set) holding the same value withUNIQUE(409), whosefieldnames the declaration that refused (a compound: its members,,apart, withfieldsandvalues). A missing field,nulland""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=Flists them, a page at a time (limit,cursor,next_cursor). Remove or change them, then apply the file again. - Applying is idempotent.
substrio deploysends each entry asindex.create(if_not_exists); the answer comes after the keys are built.index.listshows a field's state, andindex.droptakes 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 deployapplies them to the app your profile names.substrio pushcarries the schema, rules, indexes and aggregates in the version, so a sandbox and, aftersubstrio promote, production can see what is declared; it warns that it does not itself declare (apply) the indexes, expiry or aggregates, onlysubstrio deploydoes.
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.