Blog

One YAML file backend: tables, RLS, auth and functions

A clinic appointments backend in one instancez.yaml: tables, RLS per clinic, auth, file storage, a SQL RPC and a Node function, with types generated from it.

With instancez, the whole backend lives in one file, instancez.yaml. Tables, row-level security, auth settings, storage buckets, SQL functions and JavaScript functions all sit in that one file. inz dev turns it into a Postgres schema and serves the Supabase HTTP API, so @supabase/supabase-js talks to it unchanged.

This post shows a complete file for a realistic app, a clinic appointments backend, and walks through each block. Then it covers what makes the file pleasant to work with day to day: hot reload, a dashboard that saves back to the file, and generated TypeScript types. We ran every example here against inz dev --embedded-pg before publishing.

What goes in instancez.yaml?

Each top-level key maps to one part of the backend:

BlockWhat it becomes
tablesPostgres tables, columns, constraints, indexes
tables.*.rlsPostgres RLS policies, checked on every request
authSign-up rules, token lifetimes, redirect allowlist, OAuth
storageBuckets with size and MIME limits, plus RLS on objects
rpcPostgres functions served at /rest/v1/rpc/<name>
functionsNode.js handlers served at /functions/v1/<name>
server, providersCORS, limits, timeouts, storage and email providers

There are no migration files next to it. instancez diffs the YAML against the live database and applies the change. The declarative migrations post goes into how that works and what it refuses to do.

A complete instancez.yaml

The example is a backend for a group of clinics. Each clinic has staff (doctors, nurses, reception) and patients. Staff book appointments and upload documents such as referral letters. Patients with an account can sign in and see their own appointments. The file enforces these rules:

  • Staff see and change rows from their own clinic only.
  • A patient sees only their own patient record and their own appointments, and can’t change them.
  • Nobody can sign up on their own. Staff and patient accounts are created server-side with the secret key.
version: 1

project:
  name: clinic

providers:
  storage:
    type: local

auth:
  allow_signup: false

tables:
  clinics:
    rls_enabled: true
    fields:
      - name: id
        type: uuid
        primary_key: true
        default: uuid_v4()
      - name: name
        type: text
        required: true
      - name: timezone
        type: text
        required: true
    rls:
      - operations: [select]
        using: "id = my_clinic()"

  staff:
    rls_enabled: true
    fields:
      - name: user_id
        primary_key: true
        foreign_key: { references: auth.users.id, on_delete: cascade }
      - name: clinic_id
        required: true
        foreign_key: { references: clinics.id }
      - name: full_name
        type: text
        required: true
      - name: role
        type: text
        required: true
        enum: [doctor, nurse, reception]
    rls:
      - operations: [select]
        using: "clinic_id = my_clinic()"

  patients:
    rls_enabled: true
    fields:
      - name: id
        type: uuid
        primary_key: true
        default: uuid_v4()
      - name: clinic_id
        required: true
        foreign_key: { references: clinics.id }
      - name: user_id
        unique: true
        foreign_key: { references: auth.users.id, on_delete: set_null }
      - name: full_name
        type: text
        required: true
      - name: date_of_birth
        type: date
        required: true
        check: "date_of_birth <= current_date"
      - name: phone
        type: text
        pattern: "^\\+[1-9][0-9]{6,14}$"
    rls:
      - operations: [select, insert, update, delete]
        using: "clinic_id = my_clinic()"
        with_check: "clinic_id = my_clinic()"
      - operations: [select]
        using: "user_id = auth.uid()"

  appointments:
    rls_enabled: true
    fields:
      - name: id
        type: uuid
        primary_key: true
        default: uuid_v4()
      - name: clinic_id
        required: true
        foreign_key: { references: clinics.id }
      - name: patient_id
        required: true
        foreign_key: { references: patients.id }
      - name: clinician_id
        required: true
        foreign_key: { references: staff.user_id }
      - name: starts_at
        type: timestamptz
        required: true
      - name: ends_at
        type: timestamptz
        required: true
        check: "ends_at > starts_at AND ends_at <= starts_at + interval '4 hours'"
      - name: status
        type: text
        required: true
        default: scheduled
        enum: [scheduled, checked_in, completed, cancelled, no_show]
    indexes:
      - columns: [clinic_id, starts_at]
    rls:
      - operations: [select, insert, update, delete]
        using: "clinic_id = my_clinic()"
        with_check: >-
          clinic_id = my_clinic()
          AND patient_id IN (SELECT id FROM patients WHERE clinic_id = my_clinic())
          AND clinician_id IN (SELECT user_id FROM staff WHERE clinic_id = my_clinic())
      - operations: [select]
        using: "patient_id IN (SELECT id FROM patients WHERE user_id = auth.uid())"

storage:
  documents:
    max_size: 20MB
    types: [application/pdf, image/jpeg, image/png]
    rls:
      - operations: [select, insert, update, delete]
        using: "split_part(name, '/', 1) = my_clinic()::text"
        with_check: "split_part(name, '/', 1) = my_clinic()::text"

rpc:
  my_clinic:
    language: sql
    volatility: stable
    security: definer
    auth_required: true
    set: { search_path: public }
    returns: { type: uuid }
    body: |
      SELECT clinic_id FROM staff WHERE user_id = auth.uid();
  day_schedule:
    language: sql
    volatility: stable
    auth_required: true
    args: [{ name: day, type: date, required: true }]
    returns:
      type: "table(id uuid, starts_at timestamptz, status text, patient text, clinician text)"
    body: |
      SELECT a.id, a.starts_at, a.status, p.full_name, s.full_name
      FROM appointments a
      JOIN clinics c ON c.id = a.clinic_id
      JOIN patients p ON p.id = a.patient_id
      JOIN staff s ON s.user_id = a.clinician_id
      WHERE (a.starts_at AT TIME ZONE c.timezone)::date = day
      ORDER BY a.starts_at;

functions:
  reminders:
    runtime: node
    file: functions/reminders.js
    auth_required: true

That’s 160 lines, and it’s the whole backend. inz validate reports Schema valid, and inz vet reports no findings, with 21 of 21 checks passed.

How do the tables and constraints work?

Every column is declared. instancez never adds an id or timestamps for you, so what you read is what Postgres gets.

A few details:

  • foreign_key with references: auth.users.id ties staff and patients to the built-in users table. When the type is left out, as in staff.user_id, it’s taken from the referenced column. A patient record doesn’t need a login, so patients.user_id is optional, and on_delete: set_null keeps the record if the account is deleted.
  • enum becomes a CHECK constraint. Booking with status: 'maybe' fails in Postgres with violates check constraint "appointments_status_check".
  • check takes a raw SQL expression. On ends_at it rejects an appointment that ends before it starts or runs longer than four hours, and on date_of_birth it rejects dates in the future.
  • pattern on phone requires E.164 format (+447700900123), so a number like 0123 is rejected.
  • default: uuid_v4() gives each row a random UUID, and the [clinic_id, starts_at] index serves the most common query, a clinic’s day.

These are Postgres constraints, so they hold for every client, including a buggy frontend or a script that uses the secret key.

How is row-level security written?

RLS is the only authorization layer in instancez. Each entry under rls turns into a Postgres policy. using decides which existing rows a request can see or target, and with_check decides what a written row may look like.

Most policies here compare a row’s clinic_id with my_clinic(), a small SQL function declared under rpc that returns the caller’s clinic. It runs with security: definer, so it can read staff without going through the staff table’s own policy, which would otherwise call itself. Its search_path is pinned, and auth_required: true turns away anonymous calls.

patients and appointments each have two policies, and Postgres allows a row if either one matches:

  • Staff get all four operations on rows where clinic_id = my_clinic().
  • Patients get select only, on rows linked to their own account.

The with_check on appointments goes further than the clinic. The patient and the clinician must belong to the caller’s clinic too. Without it, a receptionist could book an appointment in their own clinic for another clinic’s patient by guessing an id, because foreign keys don’t look at RLS. When we tried that, Postgres answered new row violates row-level security policy for table "appointments".

We ran it with two clinics, a doctor at the first, a receptionist at the second and two patients at the first. The receptionist got zero rows from the first clinic’s appointments, even when filtering by its clinic_id. Each patient saw exactly one appointment, their own, and a patient’s update changed zero rows.

staff.role is plain data here. No policy branches on it, and you’d add that yourself if nurses and reception need different access. rls_enabled: true with no policies would deny everything to clients, which is why clinics and staff have no insert policy: you manage them with the secret key. The RLS in YAML post covers more patterns and how inz vet catches open policies in CI.

How do storage buckets work?

The documents bucket takes PDF, JPEG and PNG files up to 20 MB. It’s private, so every read and write goes through the bucket’s rls. Object paths start with the clinic id, as in <clinic_id>/<patient_id>/referral.pdf, and the policy compares that first folder with my_clinic().

In our run, the doctor uploaded and listed a referral letter. The receptionist at the other clinic got Not authorized to write this object on upload, Object not found on download and an empty list. A patient also got Object not found, since no policy grants patients access to documents. An HTML upload was turned away with Content type "text/html" not allowed.

What’s the difference between rpc and functions?

rpc declares a Postgres function. day_schedule runs as the caller (security: invoker is the default), so the RLS policies above still apply and each clinic only gets its own day. It uses the clinic’s timezone to decide which appointments fall on that day. auth_required: true turns anonymous calls away with This function requires authentication.

functions declares a JavaScript handler that runs on Node.js. Here’s functions/reminders.js, which builds reminder messages for the next 24 hours:

export default async function handler(req, ctx) {
  const { data: clinicId } = await ctx.supabase.rpc("my_clinic");
  if (!clinicId) return { status: 403, body: { error: "staff only" } };

  const now = new Date();
  const until = new Date(now.getTime() + 24 * 60 * 60 * 1000);
  const { data, error } = await ctx.supabase
    .from("appointments")
    .select("starts_at, patients(full_name, phone), clinics(name, timezone)")
    .eq("status", "scheduled")
    .gte("starts_at", now.toISOString())
    .lt("starts_at", until.toISOString())
    .order("starts_at");
  if (error) return { status: 400, body: { error: error.message } };

  const reminders = data
    .filter((a) => a.patients.phone)
    .map((a) => {
      const when = new Intl.DateTimeFormat("en-GB", {
        timeZone: a.clinics.timezone,
        weekday: "short", hour: "2-digit", minute: "2-digit",
      }).format(new Date(a.starts_at));
      return {
        to: a.patients.phone,
        text: `Hi ${a.patients.full_name}, a reminder of your appointment at ${a.clinics.name} on ${when}.`,
      };
    });
  return { status: 200, body: { count: reminders.length, reminders } };
}

ctx.supabase carries the caller’s JWT, so RLS scopes the query to the caller’s clinic without any clinic checks in JS. The function only builds the payloads. Handing them to an SMS or email provider is one more call with an API key from the function’s env: block. Patients get a 403 from the first check, and anonymous callers a 401 from auth_required.

Use rpc for logic that fits in SQL, and functions for webhooks, third-party APIs and anything that needs npm packages. One difference from Supabase: functions run on Node.js, not Deno, so Deno Edge Functions need porting. The code functions docs have the full req and ctx reference.

What does it look like from supabase-js?

Nothing about the client is specific to instancez. Since sign-up is off, accounts are created on the server with the secret key:

import { createClient } from '@supabase/supabase-js'

const url = 'http://localhost:8080'

// Server side only: the secret key bypasses RLS.
const admin = createClient(url, process.env.INSTANCEZ_SECRET_KEY)
const { data: { user } } = await admin.auth.admin.createUser({ email, password, email_confirm: true })
await admin.from('staff').insert({ user_id: user.id, clinic_id, full_name: 'Dr. Lee', role: 'doctor' })

// In the app, with the publishable key.
const supabase = createClient(url, process.env.INSTANCEZ_PUBLISHABLE_KEY)
await supabase.auth.signInWithPassword({ email, password })

await supabase.from('appointments').insert({
  clinic_id, patient_id, clinician_id: user.id,
  starts_at: '2026-11-03T09:30:00Z', ends_at: '2026-11-03T10:00:00Z',
})
await supabase.from('appointments').select('starts_at, status, patients(full_name)')
await supabase.rpc('day_schedule', { day: '2026-11-03' })  // [{ patient: 'Ana Silva', clinician: 'Dr. Lee', ... }]
await supabase.functions.invoke('reminders')               // { count: 1, reminders: [...] }
await supabase.storage.from('documents').upload(`${clinic_id}/${patient_id}/referral.pdf`, file)

If you already have an app on Supabase, the migration guide covers moving it over.

How does hot reload work?

inz dev watches instancez.yaml and the functions/ folder. Save the file and the schema change is applied on the spot. We added a reason text column to appointments while the server was running and queried it over REST right away. It returned 200, and the log showed:

level=INFO msg="config reloaded successfully" tables=4
level=INFO msg="functions: reloaded" count=1

The watcher handles atomic saves (write to a temp file, then rename), which is how most editors and Claude Code write files. In dev, removing a column drops it and logs a warning naming it, here applying destructive migration drops=appointments.reason. inz serve won’t drop a column or table unless you pass --allow-destructive. If you don’t have Postgres installed, --embedded-pg starts a local one for you, and the local dev post compares that to supabase start.

Can I still use a dashboard?

Yes. inz dev serves a dashboard at /dashboard, and you log in with the secret key from .development.env. Under inz dev it runs in read-write mode, and schema edits made there go back into instancez.yaml. You see the change to the YAML as a diff before it’s applied.

The file stays the source of truth even when you click. A column added in the dashboard ends up in instancez.yaml, so it goes through git and code review like any other edit. In production, inz serve turns the dashboard off by default, and in readonly mode config writes are refused, so deployed config comes from the file.

How do I get TypeScript types?

inz gen types --out database.types.ts

The command reads only the YAML. It doesn’t touch a database or the network, so you can run it before migrating, in CI, or on a laptop with no Postgres. The output has the same shape as supabase gen types typescript, so you pass it to createClient the usual way:

import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'

const supabase = createClient<Database>('http://localhost:8080', 'inz_publishable_...')

const { data } = await supabase.from('appointments').select('starts_at, status').eq('status', 'scheduled')
data?.forEach((a) => console.log(a.starts_at.slice(0, 10), a.status.toUpperCase()))

await supabase.from('appointments').update({ status: 'noshow' }).eq('id', 'some-id')
await supabase.from('appointments').update({ satus: 'no_show' }).eq('id', 'some-id')

tsc rejects the last two lines. For the first it says Type '"noshow"' is not assignable to type '"cancelled" | "checked_in" | "completed" | "no_show" | "scheduled" | undefined'. Did you mean '"no_show"'?, because the enum turns into a union type. For the second it says 'satus' does not exist and asks Did you mean to write 'status'?. Required columns such as starts_at are typed string rather than string | null, so a.starts_at.slice(0, 10) needs no null check.

To keep the types in sync, add one CI step that fails when the file drifts from the YAML:

- run: inz gen types | diff - database.types.ts

The TypeScript types docs list the type mappings and the few places where the output differs from Supabase’s.

Why one file?

You can read the whole backend top to bottom. For the clinic, that means one file answers “who can see this appointment?”, and a change to that answer shows up as a YAML diff, not a stack of SQL migrations to read in order. Coding agents get the same benefit: they read one file, edit it, and you review the diff. The coding agents post goes deeper on that.

The same file also goes to every environment. ${INSTANCEZ_ENV_*} references keep secrets and per-environment values out of it, and the dev, staging and prod post shows how one file gets promoted from your laptop to production.

What one file doesn’t give you yet: there’s no Realtime or websockets, and no database branching. The compare page lists the gaps next to Supabase.

FAQ

Do I have to write the YAML by hand?

No. You can write it yourself, have a coding agent write it, or build it from the dashboard under inz dev, which saves back to the file. All three end up in the same file.

Where do secrets go?

Not in the YAML. Reference them as ${INSTANCEZ_ENV_NAME} and set them in the environment. Function secrets go in a function’s env: block and reach the handler as ctx.env.

Is this a full Supabase replacement?

It serves the Supabase HTTP API for auth, REST, RPC, storage and functions, and supabase-js works against it. Realtime isn’t supported, and the compare page lists the other differences.

Where’s the full list of keys?

In the configuration reference.