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. Most of this page describes Postgres. A site on the newer database runs a SQLite based engine instead, and where the two differ, this page says so.

Getting a database

Projects start without one. Open the CMS tab in the editor toolbar and you will see an empty state that says No database with the description "Ask the AI to create a database." and a single Ask AI button.

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.

Provisioning takes a few seconds. When it finishes, the connection string is 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: id uuid PRIMARY KEY DEFAULT gen_random_uuid(). 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 uuid too.
  • Timestamps are named exactly created_at and updated_at, typed timestamptz. created_at is set by its default, and updated_at is re-stamped on every edit by a trigger created alongside the table.

On a site on the newer database the same conventions hold with SQLite types, which are only text, integer and real. The id is a text column filled with a generated UUID, foreign key columns are text to match, created_at and updated_at are text columns holding the time, and the trigger still re-stamps updated_at. Booleans are stored as 1 and 0, and JSON, lists and dates as text.

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 file uploader, an image uploader, a multi reference picker, or one field with language tabs. On a site on the newer database it also marks which integer column is a boolean and which text column holds a date, JSON, a list or an image gallery, because 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, each statement gets a 15 second timeout, and a result set comes back to the AI capped at 200 rows. Long running work has to be broken into steps.

On a site on the newer database the character and row caps are the same, but there is no per statement timeout. The AI stops waiting for an answer after 30 seconds, which does not stop the statement itself, so it keeps each statement small. The statement also has to be written for SQLite: Postgres only syntax, such as ADD COLUMN IF NOT EXISTS or a plpgsql function, fails there.

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

The connection string is stored as an encrypted project secret named DATABASE_URL and injected into your site's environment. There is one value, shared by the preview, development and production machines.

Read it from server side code with any Postgres client. The AI typically installs pg, drizzle-orm or kysely and reads the value from process.env:

const connectionString = process.env.DATABASE_URL

The value is never hardcoded into the codebase, and it is never read from the browser.

A site on the newer database is reached over HTTP rather than a Postgres connection, so a Postgres client such as pg cannot talk to it. There, DATABASE_URL is the address of the database and a second secret, DATABASE_TOKEN, is the token that scopes requests to this site. The AI reads both from process.env in server side code and typically uses drizzle-orm with its SQLite proxy driver.

The DATABASE_URL secret is locked

DATABASE_URL appears in the Secrets tab with a Managed badge. Hovering it says "This secret is managed by Modulify and cannot be edited or removed." You can reveal and copy the value, but you cannot change or delete it, and neither can the AI. It lives and dies with the database itself. On a site on the newer database, DATABASE_TOKEN is locked the same way.

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

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 on the newer database, and the Secrets tab reveals and copies them like any other value, as The DATABASE_URL secret is 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. Use the refresh button in the Collections header to retry.

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. Neither hands back the connection: a client reads DATABASE_URL only with get_secret, behind the credentials:reveal scope you tick yourself, exactly as revealing it 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. 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 ten times in twenty four hours with Back up now, 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 Back up now 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: Back up now 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