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 up | What it means |
|---|---|
axhub.yaml declaration | database: engine: postgres is already there |
| Connection code | db() in lib/db.ts handles connecting |
| Creating tables | Write your CREATE TABLE inside ensureSchema() in the same file |
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 freshDATABASE_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.
database:
engine: postgresengine 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 variable | Purpose |
|---|---|
DATABASE_URL | The address for everyday reads and writes. It goes through a relay that pools connections for you (a connection pooler) in transaction mode |
DIRECT_DATABASE_URL | For work that needs session locks, like changing table structure (migrations). It goes through the pooler in session mode |
Both addresses carry sslmode=disable.
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]);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.
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
- 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 setowner_bypass_rls: true. - Connect: migrations use
DIRECT_DATABASE_URL(existing account), regular requestsDATABASE_URL_RUNTIME, loginDATABASE_URL_AUTH. - Grant: in migrations,
GRANTto thecurrent_database() || '_<name>'account and create RLS policies. The platform grants nothing. - Set the customer: per request, inside a transaction, with
SET LOCALorset_config(…, true). Never plainSET. - Check: after deploying, reading another customer's rows or the auth tables as
runtimemust fail.
Write the account names in axhub.yaml and AxHub creates the accounts and injects their connection strings.
database:
engine: postgres
roles: [runtime, auth] # extra accounts (up to 3)
owner_bypass_rls: true # optional — when you use FORCE RLSAfter a deploy, each account gets two strings on top of the existing ones.
| Environment variable | Account | Used for |
|---|---|---|
DATABASE_URL · DIRECT_DATABASE_URL | The existing account (table owner) | Migrations · admin work — unchanged |
DATABASE_URL_RUNTIME · DIRECT_DATABASE_URL_RUNTIME | runtime | Regular API requests |
DATABASE_URL_AUTH · DIRECT_DATABASE_URL_AUTH | auth | Login · 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.
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.
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
roandstagingare 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_rlstakes 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 stagingInside 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_URLyourself — on an app with the DB enabled, the value AxHub injects always wins (the same goes for an extra account'sDATABASE_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.