Skip to main content

Managing Supabase Schemas with Atlas

Supabase gives each project a managed PostgreSQL instance and a Data API that serves the exposed schemas over HTTP. The API is generated from the schema, which makes the schema the authorization surface, as well. The grants on a table and the row-level security policies attached to it decide what an anonymous HTTP request can read and write.

Atlas connects to a Supabase project over postgres://, the same as any other PostgreSQL database. This guide covers picking a connection endpoint that works, building a dev database that matches the project, both migration workflows, and keeping RLS policies and Data API grants in the schema file next to the tables they protect.

Prerequisites

  1. Docker, for the dev database.
  2. Atlas installed on your machine:

To download and install the latest release of the Atlas CLI, simply run the following in your terminal:

curl -sSf https://atlasgo.sh | sh
  1. A Supabase project and its database password.

The docker block that builds the dev database, and permission (grant) management, are available to Atlas Pro users. Run the following command to use them:

atlas login

Connecting to a Supabase Project

The Connect dialog in the project dashboard offers three endpoints, and they do not behave the same under Atlas:

EndpointHost and portUse with Atlas
Direct connectiondb.[project-ref].supabase.co:5432Use this one. Reachable over IPv6, or over IPv4 with the IPv4 add-on.
Session pooleraws-[region].pooler.supabase.com:5432, user postgres.[project-ref]Fallback for IPv4-only networks without the add-on, such as CI runners.
Transaction pooleraws-[region].pooler.supabase.com:6543, user postgres.[project-ref]No.

Prefer the direct connection. It is the endpoint Supabase documents for migrations, and the one that gives Atlas an unpooled session: atlas migrate apply holds a session-level advisory lock for the duration of the run, so a second deployment cannot apply migrations to the same database at the same time.

Transaction mode is the one that breaks. It gives every transaction a different backend, so the lock is taken on one connection while the migration runs on another, and Supabase documents that it does not support prepared statements. Session mode keeps one backend per client connection, so the lock holds there, but Supabase recommends it only as an alternative to the direct connection on IPv4-only networks. Reach for it when the machine running Atlas has no IPv6 route and the project has no IPv4 add-on.

Export the URL with sslmode=require, and percent-encode any special character in the password:

export SUPABASE_URL="postgres://postgres:[PASSWORD]@db.[project-ref].supabase.co:5432/postgres?search_path=public&sslmode=require"

search_path=public scopes every Atlas command to the one schema you own. Supabase owns the rest, including auth, storage, realtime, graphql, extensions, vault, supabase_functions, and supabase_migrations. Objects in those schemas belong to the supabase_admin role, and Atlas has no reason to inspect or plan them.

To manage more than one of your own schemas, drop search_path from the URL and list them on the env instead:

atlas.hcl
env "supabase" {
url = getenv("SUPABASE_URL")
schemas = ["public", "app"]
}

Configuring a Dev Database

Atlas plans migrations against an ephemeral dev database, an empty database used as a scratch space to compute the diff. For a Supabase schema, we cannot begin with the stock postgres container. Policies call auth.uid(), grants name the anon and authenticated roles, and column defaults call functions that live in the extensions schema. None of that exists in an empty database, so planning fails before it starts.

Instead, create the objects your schema depends on in a baseline script:

20260826120000_baseline.sql
-- The roles the Data API connects as. Grants and policies reference them by name.
CREATE ROLE anon NOLOGIN NOINHERIT;
CREATE ROLE authenticated NOLOGIN NOINHERIT;
CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS;

-- Supabase installs extensions in a schema of their own.
CREATE SCHEMA IF NOT EXISTS extensions;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA extensions;

-- Stand-ins for the auth objects the schema references, with the same signatures
-- as the ones Supabase installs.
CREATE SCHEMA IF NOT EXISTS auth;

CREATE TABLE IF NOT EXISTS auth.users ("id" uuid NOT NULL, PRIMARY KEY ("id"));

CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS $$
SELECT nullif(
coalesce(
current_setting('request.jwt.claim.sub', true),
current_setting('request.jwt.claims', true)::jsonb ->> 'sub'
),
''
)::uuid
$$;

The auth.users stub is there for schemas whose tables carry a foreign key into it. Drop the objects you do not reference, and add the ones you do: the extensions your project enables, and any other helper your policies call, such as auth.jwt().

Now point a docker block at the script, on the same PostgreSQL version as the project:

atlas.hcl
docker "postgres" "dev" {
image = "postgres:17"
schema = "public"
baseline = file("20260826120000_baseline.sql")
}

env "supabase" {
url = getenv("SUPABASE_URL")
dev = docker.postgres.dev.url
schema {
src = "file://schema.pg.hcl"
}
migration {
dir = "file://migrations"
revisions_schema = "atlas"
}
}

revisions_schema keeps the atlas_schema_revisions table out of public. Without it, Atlas stores its migration history in the schema the URL is bound to, which on Supabase is the schema the Data API exposes. Atlas creates the schema named here if it does not exist.

When the stock image is not enough

A baseline script can stand in for an object, but not for a binary. CREATE EXTENSION pg_graphql, pg_net, pg_cron, supabase_vault, or pgsodium fails on a stock PostgreSQL image because the extension is not installed there. If your schema installs any of them, point the docker block at the image your project runs instead, and drop the baseline attribute:

atlas.hcl
docker "postgres" "dev" {
image = "supabase/postgres:17.6.1.063"
schema = "public"
}

Four things change with that image:

  1. schema = "public" stops being optional. The image ships the platform schemas, so without it the dev database is rejected: connected database is not clean: found schema "auth".
  2. The baseline.sql above no longer applies. The image already has the roles, the extensions schema, and auth, so replaying it fails with role "anon" already exists.
  3. The auth schema is read-only to you. It belongs to supabase_admin, and the image demotes postgres from superuser, so adding a missing helper fails with permission denied for schema auth. The tag has to ship the helpers your policies call: 17.6.1.063 has auth.uid() but not auth.jwt().
  4. With permissions = true, the image's default privileges become part of the desired state. Every new table in public comes back holding all privileges for anon, authenticated, and service_role, which is the pre-2026 Supabase default rather than what your schema file declares.

The image is also around 3 GB against 476 MB, which is worth a thought in CI.

warning

Neutralizing the image's demote-postgres init script is a common workaround for points 2 and 3, and it makes postgres a superuser on the dev database. A hosted project never grants that, so a schema that needs it plans locally and then fails on the project itself. Reach for it only when you manage objects that genuinely require superuser, such as event triggers.

Inspecting the Project

With the env in place, write the current state of the project to a schema file:

atlas schema inspect --env supabase > schema.pg.hcl
schema.pg.hcl
table "todos" {
schema = schema.public
column "id" {
null = false
type = uuid
default = sql("gen_random_uuid()")
}
column "user_id" {
null = false
type = uuid
}
column "title" {
null = false
type = text
}
column "completed" {
null = false
type = boolean
default = false
}
primary_key {
columns = [column.id]
}
row_security {
enabled = true
}
}

schema "public" {
comment = "standard public schema"
}

The inspected state carries the RLS settings and policies of each table, not just its columns and indexes, which is what makes them reviewable in a pull request. Atlas Pro users can render the same state as an ERD by adding -w to the command.

info

For inspecting specific schemas, excluding objects, and the other output formats, see the atlas schema inspect documentation.

Versioned Migrations

In the versioned workflow, every change is a file in a migration directory, checked in and reviewed like any other code. Atlas plans the files for you.

Baselining an existing project

A project that already has tables, whether they were created in the dashboard, in the SQL editor, or by the Supabase CLI, needs a starting point. Generate a migration that represents the current state:

atlas migrate diff baseline --env supabase --to "$SUPABASE_URL"

Atlas writes the file and an atlas.sum file that protects the integrity of the directory. Mark that version as the baseline on the first apply run (e.g. 20260826120000) so Atlas records it without executing it:

atlas migrate apply --env supabase --baseline "<version>"

On a new project with an empty public schema, skip both steps above and plan the first migration from the schema file instead:

atlas migrate diff initial --env supabase

Planning a change

Make a change to the schema file. For example, add a column:

schema.pg.hcl
table "todos" {
schema = schema.public
// ... columns from above ...
column "due_at" {
null = true
type = timestamptz
}
}

Run the following command to generate a migration file:

atlas migrate diff add_due_at --env supabase

Review the SQL generated by Atlas:

20260826121500_add_due_at.sql
-- Modify "todos" table
ALTER TABLE "todos" ADD COLUMN "due_at" timestamptz NULL;

Apply the migration to your Supabase project:

atlas migrate apply --env supabase

Declarative Migrations

The declarative workflow skips the migration directory. Instead, the schema file is the desired state, and Atlas computes the plan against the live project on each run.

atlas schema apply --env supabase

Atlas prints the plan and waits for approval before applying any changes. Add --dry-run to print it the plan without applying.

The schema file is authoritative for the schemas in scope, so an object in public that is not in the file is planned for deletion. If part of public belongs to someone else, exclude it from the env:

atlas.hcl
env "supabase" {
url = getenv("SUPABASE_URL")
dev = docker.postgres.dev.url
// Leave every function in "public" to whoever created it.
exclude = ["*[type=function]"]
schema {
src = "file://schema.pg.hcl"
}
}

Patterns are matched against the scope of the URL. The single-part pattern above works because the URL is bound to one schema with search_path=public.

RLS Policies and Data API Grants as Code

Two independent layers decide what the Data API can do with a table:

  1. Grants. anon and authenticated need a GRANT on the table before PostgREST can see it. Projects created since May 2026 no longer grant this automatically, and Supabase is applying the same default to existing projects starting October 2026, so grants belong in the schema rather than inma side effect of CREATE TABLE.
  2. Row-level security. Grants decide whether a role reaches the table, and RLS policies decide which rows it sees. A table with grants and RLS disabled is readable in full by anyone holding an anon key.

Both are schema objects, so they live next to the table. Permissions are excluded from inspection by default, so enable them on the env:

atlas.hcl
env "supabase" {
url = getenv("SUPABASE_URL")
dev = docker.postgres.dev.url
schema {
src = "file://schema.pg.hcl"
mode {
permissions = true
}
}
}

Leave roles off. With permissions = true alone, Atlas plans grants and names each grantee by name without inspecting or planning roles, which is what you want on Supabase: the platform owns every role in the instance, including anon, authenticated, and service_role.

Now the table, its policies, and its grants exist in one file:

schema.pg.hcl
table "todos" {
schema = schema.public
// ... columns from above ...
row_security {
enabled = true // ENABLE ROW LEVEL SECURITY
enforced = true // FORCE ROW LEVEL SECURITY
}
}

policy "todos_select_own" {
on = table.todos
for = SELECT
to = ["authenticated"]
using = "((select auth.uid()) = user_id)"
}

policy "todos_insert_own" {
on = table.todos
for = INSERT
to = ["authenticated"]
check = "((select auth.uid()) = user_id)"
}

policy "todos_update_own" {
on = table.todos
for = UPDATE
to = ["authenticated"]
using = "((select auth.uid()) = user_id)"
check = "((select auth.uid()) = user_id)"
}

policy "todos_delete_own" {
on = table.todos
for = DELETE
to = ["authenticated"]
using = "((select auth.uid()) = user_id)"
}

// Without this grant the table is invisible to the Data API.
permission {
to = "authenticated"
for = table.todos
privileges = [SELECT, INSERT, UPDATE, DELETE]
}

// PostgreSQL grants USAGE on the public schema to PUBLIC. With permissions = true
// that grant is part of the desired state, so declare it or Atlas revokes it.
permission {
to = PUBLIC
for = schema.public
privileges = [USAGE]
}

Four details in the schema above are worth calling out:

  1. enforced = true maps to FORCE ROW LEVEL SECURITY, which applies the policies to the table owner too. Without it the owner, postgres, reads and writes every row regardless of policy.
  2. Every policy names its role with to. A policy without it applies to every role, including anon.
  3. using filters reads and the rows an UPDATE or DELETE can see. check validates the rows an INSERT or UPDATE produces. For UPDATE policies, PostgreSQL uses using as the check expression when check is omitted.
  4. auth.uid() is wrapped in a subquery so the planner evaluates it once per statement instead of once per row.

Nothing grants to service_role. It holds BYPASSRLS, so a grant to it hands out unfiltered access to the table.

warning

permissions = true brings PostgreSQL's implicit grants into the desired state, starting with USAGE on public granted to PUBLIC. An HCL schema file contains only what it declares, so leaving that grant out makes Atlas plan REVOKE USAGE ON SCHEMA "public" FROM PUBLIC, which cuts off the Data API roles. On a dev database bound to a single schema the same delta fails the plan instead, with modify schema "public" is not allowed when migration plan is scoped to one schema. A SQL schema file runs on the dev database, so it keeps the grant without declaring it. See Default Privileges for the full picture.

note

Projects created before the grants change carry an ALTER DEFAULT PRIVILEGES that hands every new table in public to anon, authenticated, and service_role. Atlas plans against the state before the table exists, so the first apply leaves those grants in place, and the next run brings the table down to what the schema file declares:

REVOKE DELETE, INSERT, MAINTAIN, REFERENCES, SELECT, TRIGGER, TRUNCATE, UPDATE ON TABLE "todos" FROM "anon", "service_role";
REVOKE MAINTAIN, REFERENCES, TRIGGER, TRUNCATE ON TABLE "todos" FROM "authenticated";

That is the schema converging on least privilege, and it is worth knowing before it runs against a project whose Data API depends on the implicit grants.

info

To block the next table that lands without RLS, and to test that the policies above actually isolate one user from another, see Row-Level Security as Code. For roles, users, and grants across the whole database, see Security as Code.

Linting and Applying from CI

Each CI platform has an Atlas component that runs these commands as pipeline steps: migrate lint on a pull request and migrate apply on merge for the versioned workflow, schema plan and schema apply for the declarative one. Point them at the config file and the env, file://atlas.hcl and supabase, so the pipeline builds its dev database from the same baseline script, and writes revisions to the same atlas schema, as your local runs:

Store the direct connection URL in the SUPABASE_URL secret, the variable the env reads with getenv. GitHub-hosted runners have no IPv6 route, and other hosted runners often do not either, so the direct connection reaches the project only with the IPv4 add-on. Without it, this is the case the session pooler is there for: point the CI secret at it, and keep the direct connection everywhere else.

Next Steps