Data model (CONFIG_DIR)
Declare realms, databases and collections with schema.json and indexes.json.
The data model is a directory tree under CONFIG_DIR, loaded once at startup. Directories define realms and databases, and a schema.json defines a collection.
$CONFIG_DIR/<realm>/
realm.yaml # required — realm settings
email/<kind>/ # optional — email templates (see Functions → Email)
<database>/<collection>/
schema.json # required — JSON Schema draft 2020-12
indexes.json # optional — index declarations
collection.yaml # optional — access rules, file fields, soft delete, erase policy
rules.test.yaml # optional — tests of the rules (`backd rules test`)
<database>/_functions/ # optional — server-side functions
- Each directory under
CONFIG_DIRis a realm. It must contain arealm.yaml. - Each directory inside a realm is a database, stored in the MongoDB database
<realm>__<database>. - Each directory inside a database is a collection, stored in a MongoDB collection of the same name. It must contain a
schema.json. - A realm’s
emailfolder holds its email templates, andpagesis reserved for the pages backd will serve; neither is a database, so they are never read as one and can’t be database names. - A database’s
_functionsdirectory holds its functions, not a collection. - Other plain files at the realm and database levels are ignored, as are hidden entries (names starting with
.). - Changing the config requires a restart. There is no hot-reload.
Starting from a template
backd template writes commented starter files, so you don’t have to start from an empty directory. It only needs CONFIG_DIR.
export CONFIG_DIR=./config
backd template realm --realm demo # demo/realm.yaml
backd template database --realm demo --database cms # the demo/cms/ directory
backd template database --realm demo --database blog --sample
backd template function --realm demo --database blog --name stats # a function, see Functions
backd template email-capture --realm demo --database blog # a development email function, see Functions -> Email
backd template collection-policy --realm demo --database blog --collection posts # a commented collection.yaml
With --sample, the command adds a posts collection (title, body, published, optional category, author) with explanations: schema.json, indexes.json and a collection.yaml with commented rules: (anyone reads published posts, signed-in users write posts signed with their own email, authors edit and delete their own but can’t change who wrote them), and a collection.yaml that anonymizes the byline when a user is erased. backd template realm --realm demo --sample creates the realm plus a database named main holding that sample: the same database as the blog realm in examples/config (whose realm.yaml is adjusted for the example app).
The command prints each file it created and each one it left unchanged because it already existed.
For a complete config repository in one step — the realm and sample database above, a sample function with tests, Dockerfiles and a local development stack (MongoDB, backd, the functions executor and egress), and a GitHub Actions CI workflow — use backd template project --dir <directory> --realm <realm> instead. Unlike the commands above, <directory> must not exist yet: it creates the whole tree, not just a piece of it. See Deploying config for the shape it follows and Unit testing a function’s logic for the testing helper it vendors.
backd template project --dir ./my-app --realm demo
cd my-app && cat README.md
Names
Realm, database and collection names must match ^[a-z0-9]+(?:[-_][a-z0-9]+)*$: lowercase letters and digits, optionally separated by single - or _.
- Names can’t start with
_, which is reserved for system routes and fields. - Names can’t contain
__, which separates realm and database in MongoDB names. <realm>__<database>must be shorter than 64 characters.- A realm name can be at most 54 characters, so that its system database
<realm>___systemfits MongoDB’s limit too.
schema.json
- Must be a valid JSON Schema object.
$schemamay be omitted and is then treated as draft 2020-12. Any other draft is rejected. formatkeywords are asserted: for example,"format": "email"rejects values that aren’t email addresses.- A
date-timestring can be stored as a real date: add"x-backd-store": "date"to its property (see Dates). - Must not declare or require
id,_idor_meta. These are system-owned fields thatbackdadds to every document, and clients can never set them.
Example, shop/orders/items/schema.json:
{
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"price": { "type": "number", "minimum": 0 },
"status": { "enum": ["active", "archived"] }
},
"required": ["name", "price"],
"additionalProperties": false
}
Dates
By default a format: date-time property is text: MongoDB stores the string, and comparing or sorting it as time only works when every value has the same form. Add x-backd-store to store it as a BSON Date instead, so queries, sorting and index ranges compare instants:
"starts_at": { "type": "string", "format": "date-time", "x-backd-store": "date" },
"ends_at": { "type": ["string", "null"], "format": "date-time", "x-backd-store": "date" }
- Clients don’t change. They still send and read RFC 3339 strings, and the schema still validates them. In
where,$eq,$ne,$in,$gt,$gte,$lt,$lteand$betweentake RFC 3339 strings in any offset and compare instants ({"starts_at": {"$gte": "2026-09-01T00:00:00+02:00"}}); text operators ($like,$startsWith…) are refused on them;order_byand cursors work. - Precision. MongoDB keeps dates in UTC to the millisecond, so
2026-09-01T14:00:00.123456+02:00reads back as2026-09-01T12:00:00.123Z, like_metatimestamps. - Where it is allowed. On a property (at the top or nested in objects) whose type is
string, or["string", "null"], with"format": "date-time"and none ofpattern,minLength,maxLength,enumorconst. Not inside arrays,$defs, orallOf/anyOf/oneOf/not: startup names the place and stops. The database validator then saysbsonType: datefor the field. - Rules see these fields as times: they compare with
now(document.starts_at > now,data.ends_at > data.starts_at) and withnil, never with text, whichbackdrefuses at startup. - Existing data. Turning this on for a field that already holds text doesn’t convert it. Documents written before keep the text until they are written again (an update converts the field), and queries by date skip them until then. Convert them in one go with
mongosh, for exampledb.items.updateMany({starts_at: {$type: "string"}}, [{$set: {starts_at: {$toDate: "$starts_at"}}}]), when you provision the new schema. The validator usesvalidationLevel: moderate, so documents that don’t conform yet stay readable until they are written.
indexes.json
This optional file declares the collection’s MongoDB indexes:
[
{ "fields": ["status", "-_meta.created_at"] },
{ "fields": ["email"], "unique": true }
]
fieldslists one or more fields inorder_bynotation: a-prefix means descending. Each field must be declared inschema.jsonor beid,_meta.created_at,_meta.updated_at,_meta.version,_meta.owner,_meta.created_byor_meta.updated_by. An index on_meta.ownerkeeps “owner only” access rules fast.unique(defaultfalse) rejects documents that repeat a value. A write that violates it returns409 conflict.- Unknown keys, duplicate fields within an index, and two indexes with the same fields are all rejected.
Indexes are created at startup by provisioning. Indexes that exist in MongoDB but aren’t declared here are kept and reported as warnings. An index on id alone is unnecessary, because MongoDB always indexes _id.
collection.yaml
This optional file holds a collection’s policy, in sections that can each be left out:
| Section | What it says | Documented in |
|---|---|---|
rules: | Who may read, create, update and delete documents. Without it only API keys reach the collection. | Access rules |
files: | The fields of a document that carry files | File fields |
soft_delete: | Whether deleted documents stay recoverable | Soft delete |
on_owner_delete: | What an administrator’s erase does to the collection when a user is erased (backd user delete) | below |
rules:
read: user != nil
update: user != nil && document._meta.owner == user.id
soft_delete: true
rules.test.yaml, the fixture of backd rules test, stays a separate file next to it: it is a test, not configuration. A rules.yaml in a collection directory (where the rules used to be) stops backd from loading, with a message that says to move its content under rules:.
Without on_owner_delete: an erase leaves the collection alone: its documents keep everything, which is what you want for records you must keep, such as purchases. backd template collection-policy writes a commented example of all of the sections.
on_owner_delete:
action: anonymize # delete | anonymize; leave it out to keep the user's documents
remove: [phone] # anonymize: these fields are removed
replace: # anonymize: these fields get a fixed value
buyer_name: "Erased customer"
pull: # in EVERY document of the collection: remove the user from these arrays
members: email # what the array holds: email | id
unset: # in EVERY document of the collection: clear these fields where they hold the user
paid_by: email # email | id
actionapplies to the documents the user owns (_meta.owneris their id).deleteremoves them;anonymizeremoves theremovefields, sets thereplacefields to their fixed value and clears the owner.pullandunsetreach every document of the collection, whoever owns it. Each field says what it holds,email(the user’s address, in the lower-case formbackdstores) orid.- Only realms with
auth: enabledhave users, so the file is an error in a realm withauth: disabled. backdchecks the policy againstschema.jsonat startup, so an erase leaves every document valid: named fields must exist; a required field can’t be removed or unset (make it optional, orreplaceit);replacevalues must satisfy the schema and can’t be in a unique index;pullfields must be arrays of strings withoutminItems;unsetfields must be strings.backd provisioncreates the indexes an erase searches by, so large collections aren’t scanned:_meta.ownerwhen the policy has anaction, and one on eachpullandunsetfield (they are checked inverifymode like the ones inindexes.json). Adding a policy to an existing collection builds them at the next provision.- The file is part of the config fingerprint. Applying a policy is an erase, which is an administrator’s action.
Soft delete
By default DELETE removes a document for good. A collection can keep deleted documents recoverable instead:
soft_delete:
retention: 30d # optional: remove them for good this long after the delete
soft_delete: true is the same without a retention. What changes in a collection that soft-deletes:
DELETEmarks the document (_meta.deleted_at,deleted_byand, with a retention,purge_at, at the next version) instead of removing it. No read, list, count, write or batch sees it; a unique value it held is free for a new document at once.- The trash is a separate view:
?deleted=only|includeon list and get,POST /{id}/restore, andDELETE /{id}?purge=true, each under its own rule (restoreandpurgein therules:, denied unless declared). See Soft delete. - A retention is MongoDB’s TTL index:
backd provisioncreates it on_meta.purge_atand MongoDB removes the documents in the background, within about a minute of that time. Without a retention a deleted document stays until it is purged by hand. - Unique indexes from
indexes.jsonget_meta.deleted_atas their last key, so live documents stay unique among themselves, a deleted one never clashes with anything, and documents that existed before count as live. Turningsoft_deleteon for a collection that already has a unique index builds the new index next to the old one, andbackd provisionwarns about the old one: it also covers deleted documents, so drop it once the new one exists (db.<collection>.dropIndex("<name>")) or a deleted document’s values stay taken.backdnever drops an index itself. - Erasing a user still deletes, anonymizes and clears documents that are in the trash: it works on everything the user owns.
- It doesn’t need users: a realm with
auth: disabledcan soft-delete, though it has no rules to restrict the trash.
Files and erasure
on_owner_delete covers file fields: delete queues every file of the user’s documents, and anonymize with a file field in remove queues that field’s files. Without a policy the files stay. See Keeping storage tidy.
File fields
files: declares the fields of a document that carry files, kept in the realm’s storage: max_size, types, multiple, max_files, download, versions and more. See File fields. Don’t declare them in schema.json: backd adds their schema.
Errors
Any invalid config stops startup with a non-zero exit. Every error is reported at once, each naming the offending file or directory. Examples of invalid config:
- a missing
CONFIG_DIR; - a realm without
realm.yaml, or an invalidrealm.yaml(see Realm settings); - an invalid name;
- a collection directory without
schema.json; - invalid JSON or an invalid schema;
- a schema that declares a system field;
- an invalid
indexes.json; - an invalid
rules:section, or a leftoverrules.yaml(rules live incollection.yamlnow); - an invalid
collection.yaml(an unknown field, a required field removed, a value that breaks the schema).