Connect a database

Turn on an app-scoped Postgres DB, read and write it from code, and browse it as tables in the console. Template apps already have it on.

Data your app works with — order lists, member records — lives in an app-scoped database.
Apps built from a template already have it turned on, so you can create tables and start reading and writing right away.

What you get is standard Postgres. Keep using whatever you already use — postgres, pg, Prisma, Drizzle.

If you built from a template

The Next.js and Astro templates come with all of this in place.

Already set upWhat it means
axhub.yaml declarationdatabase: engine: postgres is already there
Connection codedb() in lib/db.ts handles connecting
Creating tablesWrite your CREATE TABLE inside ensureSchema() in the same file
Reading and writing
import { db, ensureSchema } from '@/lib/db';

await ensureSchema();
const rows = await db()`SELECT * FROM todos WHERE user_key = ${userKey}`;

Values passed as ${...} are bound safely for you — never concatenate them into the string yourself.

To add a table or a column, extend the CREATE TABLE IF NOT EXISTS block inside ensureSchema(). It's plain SQL.

Developing on your machine

The template ships commands that bring up a local Postgres.

npm run db:up      # start local Postgres
npm run db:reset   # wipe the data and start fresh

DATABASE_URL in .env.local points at that local DB. On deploy it's replaced by the value AxHub injects, and your code stays the same.

If you're turning it on yourself

Without a template, add two lines to axhub.yaml and deploy.

axhub.yaml
database:
  engine: postgres

engine currently accepts only postgres. This declaration is the only way to turn on an app-scoped DB.

On the next deploy an app-scoped DB is created and the connection details arrive as environment variables.

Environment variablePurpose
DATABASE_URLThe address for everyday reads and writes. It goes through a relay that pools connections for you (a connection pooler) in transaction mode
DIRECT_DATABASE_URLFor work that needs session locks, like changing table structure (migrations). It goes through the pooler in session mode

Both addresses carry sslmode=disable.

pg driver
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const { rows } = await pool.query('SELECT * FROM orders WHERE done = $1', [false]);
Prisma (schema.prisma)
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")        // queries
  directUrl = env("DIRECT_DATABASE_URL") // migrations
}

You may need prepare: false. The relay that DATABASE_URL goes through doesn't support prepared statements, so drivers like postgres need that option turned off. If it works locally but only fails after deploying, check this first.

postgres(process.env.DATABASE_URL, { prepare: false })

production (the live service) and staging (for testing) get separate DBs. Deploying to each environment creates that environment's DB, and their data never mixes. If your app uses staging, the staging DB is created when you deploy to staging, and the production DB when you promote to production.

When you need an extension

If you need a Postgres extension — vector search (pgvector) or fuzzy text matching (pg_trgm), say — add one more line to axhub.yaml. The app has no permission to install extensions itself, so AxHub installs them for you at deploy time.

axhub.yaml
database:
  engine: postgres
  extensions: [pgvector, pg_trgm]

From the next deploy on, the extensions are ready before the app comes up on the new version. They go into the production and staging DBs separately, and adding them to a DB you're already using leaves your data untouched.

These thirteen names are accepted — pgvector (writing vector means the same) · pg_trgm · citext · unaccent · fuzzystrmatch · uuid-ossp · pgcrypto · hstore · ltree · intarray · tablefunc · btree_gin · btree_gist. A name outside the list fails the deploy before it starts, and the failure reason shows the accepted values.

You don't need to change your migration code. If it already has a statement like CREATE EXTENSION IF NOT EXISTS vector, it passes as-is because the extension is already installed.

When you need separate DB accounts

An app DB comes with one account by default. It owns the tables, so it can read and write anything. That's enough for most apps, but some need separate accounts:

  • Keeping login data apart — only the login code should read password hashes or session tables
  • One app serving many outside customers — Postgres RLS (row-level security) lets each customer see only its own rows. The table owner is not subject to RLS, so with a single account the isolation doesn't hold
Summary for AI agents
  • Use it when: the app keeps data for several outside customers (companies, organizations) in one DB, or the app has its own members/password tables. Otherwise don't (an app that only uses axhub login doesn't need it).
  • Declare: put purpose names in database.roles (e.g. runtime, auth). If you'll use FORCE RLS, also set owner_bypass_rls: true.
  • Connect: migrations use DIRECT_DATABASE_URL (existing account), regular requests DATABASE_URL_RUNTIME, login DATABASE_URL_AUTH.
  • Grant: in migrations, GRANT to the current_database() || '_<name>' account and create RLS policies. The platform grants nothing.
  • Set the customer: per request, inside a transaction, with SET LOCAL or set_config(…, true). Never plain SET.
  • Check: after deploying, reading another customer's rows or the auth tables as runtime must fail.

Write the account names in axhub.yaml and AxHub creates the accounts and injects their connection strings.

axhub.yaml
database:
  engine: postgres
  roles: [runtime, auth]      # extra accounts (up to 3)
  owner_bypass_rls: true      # optional — when you use FORCE RLS

After a deploy, each account gets two strings on top of the existing ones.

Environment variableAccountUsed for
DATABASE_URL · DIRECT_DATABASE_URLThe existing account (table owner)Migrations · admin work — unchanged
DATABASE_URL_RUNTIME · DIRECT_DATABASE_URL_RUNTIMEruntimeRegular API requests
DATABASE_URL_AUTH · DIRECT_DATABASE_URL_AUTHauthLogin · sessions

DATABASE_URL_<NAME> goes through the pooler per transaction and DIRECT_DATABASE_URL_<NAME> per session — the same rule as the existing two.

Grant access in your migrations

A new account can connect but read no tables at first. Your app decides who sees what. Put this in a migration that runs as the existing account (the table owner).

Account names are <DB name>_<name you wrote>. Production and staging have different DB names, so build the name with current_database() instead of writing it out.

migration.sql
DO $$
DECLARE
  runtime_role text := current_database() || '_runtime';
  auth_role    text := current_database() || '_auth';
BEGIN
  EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON deals TO %I', runtime_role);
  EXECUTE format('GRANT SELECT, INSERT, UPDATE ON users, sessions TO %I', auth_role);
  -- to give tables created later the same access
  EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO %I', runtime_role);
END $$;

-- each customer sees only its own rows
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_only ON deals
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

The app sends regular requests through DATABASE_URL_RUNTIME and login through DATABASE_URL_AUTH. The runtime account then sees only that customer's rows and can't read users.

Set the customer inside a transaction with SET LOCAL. DATABASE_URL_<NAME> goes through the pooler per transaction, so a value set with plain SET can vanish on the next query or linger on another request's connection.

BEGIN;
SET LOCAL app.tenant_id = '…';   -- or SELECT set_config('app.tenant_id', '…', true);
SELECT * FROM deals;              -- only that customer's rows
COMMIT;

With Prisma

Migrations keep running through DIRECT_DATABASE_URL (the existing account); the app creates one client per purpose.

lib/db.ts
import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL_RUNTIME });
export const authDb = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL_AUTH });

// customer-scoped queries — set_config's third argument true is the same as SET LOCAL
export function forTenant<T>(tenantId: string, run: (tx: typeof db) => Promise<T>) {
  return db.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return run(tx as typeof db);
  });
}

For the grants and RLS SQL, create an empty migration with npx prisma migrate dev --create-only and put the example above in its migration.sql.

When you use FORCE RLS — owner_bypass_rls

The table owner (the existing account) normally skips RLS, so a regular request sent through DATABASE_URL by mistake ignores the isolation entirely. To guard against that too, put FORCE ROW LEVEL SECURITY on the table so the owner is subject to RLS as well.

That also puts migrations, SECURITY DEFINER functions running as the owner, and AxHub's DB backup under RLS. With owner_bypass_rls: true the existing account gets the right to skip RLS (BYPASSRLS), and those three keep working. Extra accounts never get this right.

If you use FORCE RLS, also write owner_bypass_rls: true. Without it the DB backup fails — the failure reason tells you to add this setting.

Rules to know

  • A name starts with a lowercase letter and is up to 16 lowercase letters and digits. Underscores aren't allowed, and ro and staging are already taken.
  • Each account can have up to 5 connections at once. The existing account's limit is unchanged.
  • Accounts are created separately in production and staging. A promote brings the production DB in line with the reviewed staging version's declaration.
  • Removing an account from the declaration only blocks its login. Its grants stay, so writing it back restores the same access with a new password. Removing owner_bypass_rls takes the right back on the next deploy.
  • Restoring a backup brings back the grants you gave to extra accounts.
  • Turning the app DB off cuts the extra accounts too, and turning it back on restores them.

Browse it as tables in the console

To see how data actually accumulated, open the app console → Resources tab → Tables and browse rows as they are.

From the terminal, it looks like this.

axhub tables db-list --app demo                 # list tables
axhub tables db-rows orders --app demo          # browse rows
axhub tables db-rows orders --app demo --environment staging

Inside app code, the SDK reads the same thing via sdk.apps.rawDb.tables(appId) and sdk.apps.rawDb.tableRows(appId, 'orders') — SDK setup happens in Install the SDK.

All three surfaces are read-only, so you can't modify data there. And if the app owner has locked the app with a viewing policy, even admins may be blocked from viewing row contents in the console or terminal.

Good to know

  • It doesn't attach to UI-only apps — a static hosting app has no always-running server, so there's nowhere to inject the connection details. Deploying with the declaration in place fails clearly.
  • Removing the declaration doesn't delete the DB — data and injection stay. To really turn it off, use axhub apps raw-db disable --app <app> --execute. Even then your tables and data remain and only access is cut — redeploy with the declaration still in place and connection details arrive again with a fresh password.
  • Extensions aren't removed when you drop them from the declaration either — tables and data using them stay intact.
  • You can't override DATABASE_URL yourself — on an app with the DB enabled, the value AxHub injects always wins (the same goes for an extra account's DATABASE_URL_<NAME>). An environment variable you save under the same name is ignored. (An app without the DB enabled can register an external DB address itself — DB ports such as 5432 and 3306 are open to public addresses only, and company-network DBs cannot be reached. See Only common ports go out.)
  • It isn't injected retroactively into already-running apps — the environment variables arrive starting with the first deploy after you declare it.
  • The SDK has no row-write API — writes go through the app runtime's DATABASE_URL.

You've succeeded when

After the deploy finishes, the app console → Resources tab → Tables shows your app's tables and rows. Whatever your app code read and wrote through DATABASE_URL appears there as is.

Next, set up a place for images and attachments in Connect storage.