Modulify

Site databases

Every project can have its own SQL database, created and shaped by the AI.

On this page

A site database is a real SQL database that belongs to one project. Your published site reads and writes it at runtime, and you browse and edit the contents from the CMS tab.

It is not a spreadsheet or a proprietary content store. It has tables, columns, foreign keys, constraints and triggers.

This page describes the newer database, a SQLite based engine that your site reaches over HTTP. Some sites that already had a database still run on the older database, which is Postgres, and where the two differ, this page says so.

Which database your site has

  • A site created now gets the newer database, and gets it while the site is being created.
  • A site that already has a database on the older database keeps it, along with everything in it. Nothing moves it.
  • A site that has never had a database gets the newer database the first time it asks for one, however old the site is. Only a database that already exists is kept on the older database, so nothing about the site itself, its age or how it was made, keeps it there.
  • A clone, a duplicate or a template clone of a site that has a database runs on the same database as the original, so a copy of a site whose database is on the older database gets the older database too. A copy of a site that has no database is a new site, and gets the newer database while it is being created. Sharing and cloning covers what a copy takes with it.

To tell which one a site runs on, open the Secrets tab. A site on the newer database has two managed rows, DATABASE_URL and DATABASE_TOKEN, and a site on the older database has DATABASE_URL alone.

Getting a database

A site created now already has its database, so its CMS tab opens on the collections browser with no collections in it yet. An older site that has never had a database shows an empty state instead, which says No database with the description "Ask the AI to create a database." and a single Ask AI button. Chatting about something else never creates one: the database only appears the first time the AI stores data.

The button drops a prompt into chat that asks for the database and, in the same breath, asks the AI to read through your codebase and say which of your existing content would be better off as CMS collections. So the reply comes back with recommendations grounded in what you have actually built, not a blank database. You can also just describe what you want to store, and the AI provisions the database itself before it starts building:

Add a blog with posts, authors and tags. Posts need a title, cover image, body and publish date.

The newer database is ready as soon as it is created, with no wait for it to start, so the AI can build tables straight away. At that point DATABASE_URL and DATABASE_TOKEN are available to your site and the CMS tab switches from the empty state to the collections browser.

How the AI creates tables

The AI has direct SQL access to your database and uses it to create tables, add columns, write indexes and constraints, run migrations and insert seed data. It follows a fixed set of conventions so the CMS can read the result:

  • Every table gets a generated UUID primary key, an id column of type text that the database fills in. Auto incrementing integer ids are not used.
  • A human readable URL key lives in a separate slug column, never as the primary key.
  • Relations point at the target table's id, so foreign key columns are text too.
  • Timestamps are named exactly created_at and updated_at, typed text and holding the time as an ISO 8601 string in UTC. created_at is set by its default, and updated_at is re-stamped on every edit by a trigger created alongside the table.

The newer database has only three column types, text, integer and real, so booleans are stored as 1 and 0, and JSON, lists and dates as text.

On a site on the older database the same conventions hold with Postgres types: id uuid PRIMARY KEY DEFAULT gen_random_uuid(), foreign key columns typed uuid, and created_at and updated_at typed timestamptz.

The CMS treats id, created_at and updated_at as system managed. They show in the row editor as read only, and on a new row they read "Auto-generated".

Field metadata

Some things a column type cannot express on its own. A column that stores an image URL is just text as far as the database is concerned. Modulify keeps that extra information in a table called _collection_fields, which the AI writes to in the same migration that creates the column.

That metadata is what makes a column render as a color swatch, a rich text editor, a code editor, a file uploader, an image uploader, a multi reference picker, or one field with language tabs. It also marks which integer column is a boolean and which text column holds a date, JSON, a list or an image gallery, because on the newer database the column type alone cannot tell them apart. _collection_fields is never shown as a collection of its own. See Column types for what each one looks like.

Running SQL

SQL runs through the AI, not through a console. There is no query editor in the product: you ask for the change in chat and the AI runs the statement for you.

Add a "featured" boolean to posts, defaulting to false, and set it true for the three newest posts.

The SQL tool has real limits. A single statement is capped at 20,000 characters, and a result set comes back to the AI capped at 200 rows. There is no per statement timeout, but the AI stops waiting for an answer after 30 seconds, which does not stop the statement itself, so it keeps each statement small and breaks long running work into steps. The statement has to be written for SQLite: Postgres only syntax, such as ADD COLUMN IF NOT EXISTS or a plpgsql function, fails.

On a site on the older database the character and row caps are the same, each statement is stopped after 15 seconds, and statements are written for Postgres.

SQL that removes or overwrites data needs the Delete projects permission of the person chatting, the same one clearing a collection asks for: dropping a table or a column, TRUNCATE, a DELETE, a REPLACE, and an UPDATE with no WHERE that overwrites existing values. Data the same request saves first does not count, so any member can still have chat change a field's type, fill a new field, swap two fields or move data into a new table, as long as chat does it in one step, and dropping a language's own columns when you remove that language or change the default works too. Without the permission chat runs nothing and says that a workspace owner or admin can do it. Chat still removes a single item for any member, the way the CMS does.

When the AI runs a statement that changes the schema, the CMS refreshes on its own, so a new collection appears in the sidebar without a reload.

How your site reads the database

Your site reaches the newer database over HTTP, not over a database connection. Two encrypted project secrets are injected into your site's environment: DATABASE_URL holds the address of the database, and DATABASE_TOKEN holds the token that scopes each request to this site. There is one value of each, shared by the preview, development and production machines.

Read both from server side code. The AI typically uses drizzle-orm with its SQLite proxy driver and reads the values from process.env:

const databaseUrl = process.env.DATABASE_URL
const databaseToken = process.env.DATABASE_TOKEN

Neither value is hardcoded into the codebase, and neither is ever read from the browser. A Postgres client such as pg cannot talk to the newer database.

On a site on the older database, DATABASE_URL holds a Postgres connection string instead and there is no DATABASE_TOKEN. The site reads it with a Postgres client, and the AI typically installs pg, drizzle-orm or kysely for that.

The database secrets are locked

DATABASE_URL and DATABASE_TOKEN appear in the Secrets tab, each with a Managed badge. Hovering the badge says "This secret is managed by Modulify and cannot be edited or removed." You can reveal and copy each value, but you cannot change or delete either one, and neither can the AI. They live and die with the database itself.

Locked means unchangeable, not hidden. The eye icon on each row works exactly as it does on any other secret, so every member of the workspace can read the whole value out: the address in DATABASE_URL and the token in DATABASE_TOKEN, or on the older database the connection string with the username and password inside it. Treat those the way you treat any other production credential, because whoever holds them holds your data.

Copy secrets and Download secrets leave both rows out, so they never land in a copied or downloaded .env. See Copy and export.

What you can and cannot do

You can browse every collection, page through rows, search them, create rows, edit them, duplicate them, delete them, clear a collection, export data, and toggle whether system collections are visible.

You cannot add, rename or drop a column from the CMS. You cannot create a collection by clicking a button, run your own SQL in the product, or point a project at a database of your own, since a site database is always one Modulify created for it. Every schema change goes through chat, or through execute_sql from a connected AI client, as described below.

The credentials are the one thing that is not withheld. They sit in DATABASE_URL and DATABASE_TOKEN, and each row in the Secrets tab reveals and copies its value like any other, as The database secrets are locked describes. What no one can do is change them.

When the database is unavailable

If the database is not reporting as running, the CMS shows Database unavailable with "Try again in a few moments." If Modulify cannot reach the database service at all, the message is "Could not reach the database service. Your data is safe, please try again in a moment!" Neither affects your data. While the Data sub-tab is on screen, the CMS checks the database again once a minute, and the first check that reaches it brings your collections back without a reload.

From an AI client over MCP

get_site_database says whether a site has a database, whether it is running and which engine it runs, without ever returning connection details, and create_site_database gives a site a database if it does not have one, which is safe to call even when it already does. The client does not choose the engine: a site with no database gets the newer one, as Which database your site has describes. Neither tool hands back the connection: a client reads DATABASE_URL and DATABASE_TOKEN only with get_secret, behind the credentials:reveal scope you tick yourself, exactly as revealing them in the Secrets tab is a deliberate act.

A connected client works through the CMS tools, or runs a statement with execute_sql, which sits behind its own data:sql scope, unticked by default, and needs the Delete projects permission for any statement. The database backups described below have tools of their own.

See MCP tools.

Backups

On a site on the newer database, any paid plan backs up the whole database once a day, and you can take a backup yourself up to 10 times in twenty four hours with Backup, which asks you to confirm and says how many manual backups that leaves. Each backup is a .zip holding database.sql, a plain text SQL file with every table, row, index, trigger and view, and it is kept outside your storage. Backups live under the Database tab of Backups. A day where nothing changed, or a database with no tables, is passed over by the daily run.

Downloading happens in that tab only. Chat and a connected MCP client can list the backups, take one, delete one or all of them and turn daily backups on or off, with list_database_backups, create_database_backup, delete_database_backup, clear_database_backups and set_database_backup_schedule over MCP, but neither can download or restore one. See Backups from chat and MCP.

Sites on the older database are not backed up. The Database tab is still there on those sites, with Backup greyed out.

There is no restore for a database backup. To get rows back, download a backup that still holds them and add the rows again from the CMS or through chat. Backups walks through it.

The CMS Settings sub-tab has a Backups section with two rows. Database backups has a View backups button that opens that tab directly. Daily backups is a switch, on for every site until someone turns it off, and turning it off stops only the daily backup of this site's database: Backup still works and every backup already taken stays. On the free plan both rows carry a Paid badge and the switch is disabled. On a site on the older database the button and the switch are both greyed out, with no badge. See Turn daily database backups off.

Delete all database backups under Danger zone in the same sub-tab removes every finished backup at once, and needs the Delete projects permission. Clearing collections does not delete any backup.

Next