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
- Docker, for the dev database.
- Atlas installed on your machine:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
- 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:
| Endpoint | Host and port | Use with Atlas |
|---|---|---|
| Direct connection | db.[project-ref].supabase.co:5432 | Use this one. Reachable over IPv6, or over IPv4 with the IPv4 add-on. |
| Session pooler | aws-[region].pooler.supabase.com:5432, user postgres.[project-ref] | Fallback for IPv4-only networks without the add-on, such as CI runners. |
| Transaction pooler | aws-[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:
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:
-- 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:
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:
docker "postgres" "dev" {
image = "supabase/postgres:17.6.1.063"
schema = "public"
}
Four things change with that image:
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".- The
baseline.sqlabove no longer applies. The image already has the roles, theextensionsschema, andauth, so replaying it fails withrole "anon" already exists. - The
authschema is read-only to you. It belongs tosupabase_admin, and the image demotespostgresfrom superuser, so adding a missing helper fails withpermission denied for schema auth. The tag has to ship the helpers your policies call:17.6.1.063hasauth.uid()but notauth.jwt(). - With
permissions = true, the image's default privileges become part of the desired state. Every new table inpubliccomes back holding all privileges foranon,authenticated, andservice_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.
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 DDL (HCL)
- SQL
atlas schema inspect --env supabase > 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"
}
atlas schema inspect --env supabase --format '{{ sql . }}' > schema.sql
-- Create "todos" table
CREATE TABLE "todos" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"completed" boolean NOT NULL DEFAULT false,
PRIMARY KEY ("id")
);
-- Enable row-level security for "todos" table
ALTER TABLE "todos" ENABLE ROW LEVEL SECURITY;
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.
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:
- Atlas DDL (HCL)
- SQL
table "todos" {
schema = schema.public
// ... columns from above ...
column "due_at" {
null = true
type = timestamptz
}
}
CREATE TABLE "todos" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"completed" boolean NOT NULL DEFAULT false,
"due_at" timestamptz NULL,
PRIMARY KEY ("id")
);
ALTER TABLE "todos" ENABLE ROW LEVEL SECURITY;
Run the following command to generate a migration file:
atlas migrate diff add_due_at --env supabase
Review the SQL generated by Atlas:
-- 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:
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:
- Grants.
anonandauthenticatedneed aGRANTon 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 ofCREATE TABLE. - 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:
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:
- Atlas DDL (HCL)
- SQL
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]
}
CREATE TABLE "todos" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"completed" boolean NOT NULL DEFAULT false,
"due_at" timestamptz NULL,
PRIMARY KEY ("id")
);
ALTER TABLE "todos" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "todos" FORCE ROW LEVEL SECURITY;
CREATE POLICY "todos_select_own" ON "todos"
FOR SELECT TO "authenticated"
USING ((select auth.uid()) = user_id);
CREATE POLICY "todos_insert_own" ON "todos"
FOR INSERT TO "authenticated"
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "todos_update_own" ON "todos"
FOR UPDATE TO "authenticated"
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "todos_delete_own" ON "todos"
FOR DELETE TO "authenticated"
USING ((select auth.uid()) = user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "todos" TO "authenticated";
Four details in the schema above are worth calling out:
enforced = truemaps toFORCE 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.- Every policy names its role with
to. A policy without it applies to every role, includinganon. usingfilters reads and the rows anUPDATEorDELETEcan see.checkvalidates the rows anINSERTorUPDATEproduces. ForUPDATEpolicies, PostgreSQL usesusingas thecheckexpression whencheckis omitted.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.
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.
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.
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:
GitHub Actions
Atlas Actions for linting migrations on a pull request and applying them on merge.
GitLab CI
Atlas CI/CD components for merge-request linting and migration deployment in GitLab pipelines.
Bitbucket Pipes
Atlas Bitbucket Pipes for running lint and migration steps in Bitbucket pipelines.
Azure DevOps
Run migration linting and deployment as Azure DevOps pipeline tasks.
CircleCI
Atlas orbs for running lint and migration steps inside CircleCI workflows.
Terraform Provider
Manage the schema as a Terraform resource, applied with the rest of your infrastructure.
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
Row-Level Security as Code
Block tables that land without RLS in CI, and test that policies isolate one tenant from another.
Security as Code
Roles, users, and permissions as code, planned and applied by Atlas.
Vulnerable Extensions
Report installed extensions affected by a published CVE during migrate lint and schema lint.
Versioned Migrations
The migration directory, planning with migrate diff, and applying with migrate apply.
Migration Linting
Catch destructive and unsafe changes in migrate lint before they reach the database.
Schema Testing
Write tests for the logic in your schema: functions, views, triggers, and policies.