name: supabase-vps-debug
description: Debug and fix Supabase self-hosted (Docker) on VPS — auth failures, RLS policy issues, schema drift, and profile auto-creation
Supabase Self-Hosted (Docker) — VPS Debug & Fix Guide
Skills for debugging Supabase auth and RLS issues on self-hosted Docker deployments on a VPS.
Quick SSH Access Pattern
`bash
sshpass -p '$PASSWORD' ssh -o StrictHostKeyChecking=no root@$IP "$CMD"
`
Finding the Right Containers
Self-hosted Supabase on VPS often uses a prefix like deploy-vps- (not the default app- prefix). List containers:
`bash
docker ps --format '{{.Names}}' | grep -E 'db|auth|rest|kong|meta'
`
Common container naming: deploy-vps-db-1, deploy-vps-auth-1, deploy-vps-rest-1, deploy-vps-kong-1, deploy-vps-meta-1, deploy-vps-studio-1
Database Access
`bash
Direct psql (if psql installed in container)
docker exec deploy-vps-db-1 psql -U postgres -d postgres -c "$SQL"
Find postgres password (from container env — may be masked as *)
docker inspect deploy-vps-db-1 --format='{{range .Config.Env}}{{println .}}{{end}}' | grep POSTGRES
`
Common Schema Inspection Commands
`sql
-- Table structure
\d table_name
-- RLS policies on a table
SELECT polname, polcmd::text FROM pg_policy WHERE polrelid = 'table_name'::regclass::oid;
-- Check if RLS is enabled
SELECT relname, relrowsecurity FROM pg_class WHERE relname = 'table_name';
-- Foreign keys
SELECT conname, contype FROM pg_constraint WHERE conrelid = 'table_name'::regclass::oid;
-- Check if function exists
SELECT proname FROM pg_proc WHERE proname = 'function_name';
-- Check trigger
SELECT tgname, tgenabled FROM pg_trigger WHERE tgname = 'trigger_name';
`
Finding the docker-compose.yml
Common locations to search:
`bash
find /root /home /opt -name 'docker-compose.yml' 2>/dev/null
Also check: ls /opt/project/supabase/
`
Key Debugging Insight: Migrations vs Schema Drift
Symptom: Code expects columns/tables that don't exist in the database, or RLS policies block operations that should work.
Root cause: Migrations exist in /opt/project/supabase/migrations/ but were never applied to the database.
Diagnosis:
`sql
-- Check applied migrations (Supabase auth migrations only = problem)
SELECT * FROM auth.schema_migrations ORDER BY version;
-- If only old Supabase migration versions (2017-2023) exist but project migrations
-- from 2026 are missing → migrations were never applied
`
Fix: Apply missing migrations or do direct SQL correction.
RLS Policy Fix Template
When a table blocks INSERT/UPDATE/DELETE with RLS but the code expects it to work:
`sql
-- 1. Check current policies
SELECT polname, polcmd::text FROM pg_policy WHERE polrelid = 'table_name'::regclass::oid;
-- 2. Drop permissive "allow all" if present
DROP POLICY IF EXISTS "allow all" ON table_name;
-- 3. Replace with correct policies
ALTER TABLE table_name DISABLE ROW LEVEL SECURITY;
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Authenticated can view" ON table_name FOR SELECT TO authenticated USING (true);
CREATE POLICY "Users can insert own" ON table_name FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
`
Auto-Create Profiles on Signup (Gotrue/Auth Hook)
When signups create auth users but profiles fail to create:
`sql
-- 1. Ensure profile INSERT allows the user to create their own profile
DROP POLICY IF EXISTS "Admins can insert profiles" ON public.profiles;
CREATE POLICY "Users can insert own profile" ON public.profiles FOR INSERT TO authenticated
WITH CHECK (auth.uid() = user_id);
-- 2. Add integrity constraints
ALTER TABLE public.profiles ADD CONSTRAINT profiles_user_id_unique UNIQUE (user_id);
ALTER TABLE public.profiles ADD CONSTRAINT profiles_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
-- 3. Create auto-profile trigger
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.profiles (id, user_id, email, full_name, is_active)
VALUES (
gen_random_uuid(),
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
true
)
ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
`
Reading Gotrue/Auth Logs
`bash
docker logs deploy-vps-auth-1 --tail 100 | grep signup
`
Key log messages:
- -
422: Unable to validate email address: invalid format→ SMTP issue or email format - -
400: User already registered→ User exists in auth.users but profile failed - -
200 POST /signup→ Success - - IP: 31.97.243.106
- - SSH:
sshpass -p 'Marcia19671951@' ssh -o StrictHostKeyChecking=no root@31.97.243.106 - - Docker compose path:
/docker/deploy-vps/docker-compose.yml - - Env file:
/docker/deploy-vps/.env - - Container prefix:
deploy-vps- - - The
POSTGRES_PASSWORDin/docker/deploy-vps/.envwas likely reset during host restart - - All containers that connect to db share the same password from
.env - - Fix: Update
/docker/deploy-vps/.envwith correct password, thencd /docker/deploy-vps && docker compose restart - - Or run
docker inspectto see what password each container actually has--format '{{.Config.Env}}' - - Check
docker exec deploy-vps-db-1 cat /var/lib/postgresql/data/pg_hba.conf— changes may have been made inside the container filesystem (overlay) - - The container's pg_hba.conf is inside the overlayFS layers, not the host volume
- - Fix: Changes inside the container are lost on restart. Make permanent changes via a volume mount or restart the db container.
- - The
functionscontainer may have been in a different Docker network thankong - - Fix:
docker network connect deploy-vps_default deploy-vps-functions-1then restart functions - -
uid()works ONLY when called through the PostgREST API (via authenticated HTTP request) - - It does NOT work in direct psql connections (there is no
uid()function in PostgreSQL itself) - -
uid()reads from the JWT'ssub, not from a database function — so the JWT must contain the correct user ID - - PostgreSQL treats
NULL ≠ NULLin unique indexes - - Multiple rows with
NULL, NULLare all allowed - - This can cause silent duplicate INSERT failures
- - New users can log in (they have a profile)
- - But they fail RLS checks requiring
has_role(uid(), 'admin')→ they can't see other users - -
auth.users.role= Supabase Auth role (set by PostgREST from JWT) - -
public.user_roles.role= Custom app role defined by your application - -
user_repeated_signup+400: User already registered→ email already exists inauth.users(expected if user was already registered) OR double-click sent two simultaneous requests - -
422: Unable to validate email address→ SMTP configuration issue - -
200 POST /signupwithaction: login→ successful signup and immediate login - -
PGRST_JWT_SECRET— MUST match the JWT_SECRET of the Supabase project (not the anon/service keys) - -
ANON_KEY— MUST match the anon key of the Supabase project - -
SERVICE_KEY— MUST match the service_role key of the Supabase project - -
DATABASE_URL— Postgres connection string - - Set
STORAGE_ENDPOINT,STORAGE_ACCESS_KEY,STORAGE_SECRET_KEYin.env - - Point
VITE_SUPABASE_URLto Supabase Cloud (https://dauftiqcvgaydddoxqhh.supabase.co) - - No local storage container needed
Finding has_role Function Issues
The has_role function uses app_role ENUM. If it fails with function does not exist:
`sql
-- Check if app_role type exists
SELECT typname FROM pg_type WHERE typname = 'app_role';
-- Create if missing
CREATE TYPE public.app_role AS ENUM ('admin', 'moderator', 'user');
`
VPS Access
Key Commands
`bash
Check all container status
docker ps -a --format '{{.Names}} {{.Image}} {{.Status}}' | grep -E '(deploy-vps|supabase)'
Check logs for failing container
docker logs deploy-vps-auth-1 --since 10s
Check pg_hba.conf inside container
docker exec deploy-vps-db-1 cat /var/lib/postgresql/data/pg_hba.conf | grep -v '^#' | grep -v '^$'
Reload PostgreSQL config (doesn't fix password mismatch)
docker exec deploy-vps-db-1 psql -U postgres -c 'SELECT pg_reload_conf();'
Check Postgres is accepting connections from network
docker run --rm --network deploy-vps_default postgres:15 psql 'postgres://postgres:
`
Common Failure Modes
1. Password/authentication mismatch after VPS restart
If containers fail with password authentication failed for user "postgres" or SASL auth errors:
2. Container restarting after pg_hba.conf changes
If you add trust rules to pg_hba.conf but pg_reload_conf() doesn't help:
3. Edge function returns 401 from Kong after network issues
If Edge Functions work when called directly but fail via Kong with 401:
Supabase REST (PostgREST) Log Reading
`bash
docker logs deploy-vps-rest-1 --tail 50
`
Look for: Failed to query the PostgreSQL version, Listening for notifications, Schema cache loaded N Relations
Useful One-Liners
`bash
Check all tables with RLS enabled
docker exec deploy-vps-db-1 psql -U postgres -d postgres -c "SELECT relname, relrowsecurity FROM pg_class WHERE relrowsecurity = true AND relkind = 'r';"
List all RLS policies
docker exec deploy-vps-db-1 psql -U postgres -d postgres -c "SELECT schemaname, tablename, policyname FROM pg_policies WHERE policyname != 'allow all';"
Check if user has admin role
docker exec deploy-vps-db-1 psql -U postgres -d postgres -c "SELECT public.has_role('user-uuid-here', 'admin');"
`
Test API Endpoints Directly
`bash
Test DELETE
curl -s -X DELETE 'https://domain/rest/v1/table?column=eq.value' \
-H 'apikey: ' -H 'Authorization: Bearer '
Test POST
curl -s -X POST 'https://domain/rest/v1/table' \
-H 'apikey: ' -H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"col": "value"}'
`
The uid() Function is NOT PostgreSQL — It's PostgREST
uid() is provided by PostgREST, not PostgreSQL. It extracts the sub claim from the JWT in the request. This means:
The has_role() Function Must Include auth in search_path
uid() lives in the auth schema. If has_role() is defined with SET search_path = public only, calling uid() inside it will fail silently (function not found → returns NULL → has_role() returns false).
Correct definition:
`sql
CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role text)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, auth
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = _user_id AND role = _role::text
);
$$;
`
Critical: The auth schema must be in search_path so uid() is resolvable inside the function. Without it, has_role() always returns false for RLS checks — making all admin-only policies fail silently.
Frontend Code Must NOT Manually Insert Profiles (When Trigger Exists)
If on_auth_user_created trigger auto-creates profiles, the frontend code must NOT also insert into profiles. This causes:
`
duplicate key value violates unique constraint "profiles_user_id_unique"
`
Correct signup flow:
1. supabase.auth.signUp({ email, password }) → creates auth.users
2. on_auth_user_created trigger → auto-creates public.profiles
3. Frontend inserts into user_roles (or trigger does it too)
Wrong signup flow (duplicate key error):
1. supabase.auth.signUp(...) → creates auth.users
2. Trigger creates public.profiles
3. Frontend tries supabase.from("profiles").insert(...) → FAILS (user_id already exists)
PostgreSQL Multi-Column UNIQUE Doesn't Enforce NULL Rows Properly
When a UNIQUE constraint covers (user_id, role) and both columns allow NULL:
Fix: Add NOT NULL constraints or use a partial index. Also verify the constraint was actually created:
`sql
SELECT conname FROM pg_constraint WHERE conrelid = 'user_roles'::regclass;
-- If only user_roles_pkey shows up, the UNIQUE constraint was NOT created
`
RLS Policies Are Cached by PostgREST — Restart Required
PostgREST caches the schema (including RLS policies) in memory. After changing any policy, you MUST restart:
`bash
docker restart deploy-vps-rest-1
`
Without this, the old cached policies remain active — your fixes appear to have no effect.
Supabase Auth Trigger Creates Profiles — But NOT user_roles
The on_auth_user_created trigger only creates public.profiles. It does NOT create public.user_roles. So:
Fix: Either:
1. Have the trigger also insert into user_roles, or
2. Insert manually after signup (and handle duplicates with ON CONFLICT DO NOTHING)
The 'admin' Role in user_roles Is Separate from auth.users role Column
auth.users.role (the authenticated/anon role in Supabase Auth) is DIFFERENT from public.user_roles.role (the custom app role like 'admin'). Don't confuse them:
Split-Insert Problem: Trigger + Frontend Both Creating Profiles
If the on_auth_user_created trigger exists AND the frontend also calls supabase.from("profiles").insert(...), you get:
`
duplicate key value violates unique constraint "profiles_user_id_unique"
`
The trigger fires first (with ON CONFLICT DO NOTHING it doesn't error), then the frontend insert violates the UNIQUE constraint. Fix: remove the frontend's manual profile insert — the trigger handles it. Keep only auth.signUp() + user_roles.insert() in the signup flow.
Docker Container Filesystem Isolation
Host files created via SSH are NOT visible inside Docker containers. docker exec fails with "No such file or directory" even though the file exists on the host at /tmp/file. Fix: use docker cp /tmp/file.sql before executing inside the container.
Reading Auth Logs for Signup Issues
`bash
docker logs deploy-vps-auth-1 --tail 100 | grep -i 'signup\|user_repeated\|400\|422'
`
Key log entries:
Note: "User already registered" is correct auth behavior when the email genuinely exists — it is NOT a backend bug. If users are double-clicking the save button, the frontend needs button-disable logic to prevent the second request.
Identify the Correct Supabase Project (Critical for Cloud)
Users often have MULTIPLE Supabase projects and confuse which one is active. The frontend JS bundle ALWAYS contains the real project URL and anon key being used. Never trust user-provided credentials without cross-referencing the actual deployment.
To find the real Supabase project from a deployed site:
`bash
1. Get the JS bundle URL from the HTML
curl -s "https://example.com" | grep -o 'src="[^"]*\.js"'
2. Extract Supabase URL and anon key from the bundle
curl -s "https://example.com/assets/index-XXXX.js" | grep -oP 'https://[^"'"'"'\s]supabase\.co[^"'"'"'\s]'
curl -s "https://example.com/assets/index-XXXX.js" | grep -oP 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[^"'"'"'\s]+'
3. Verify with a simple auth test
curl -s -X POST "https://REF.supabase.co/auth/v1/token?grant_type=password" \
-H "apikey: ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "test@test.com", "password": "wrong"}'
A 400 = project exists and is accessible. HTTP 000 = DNS/host unreachable.
`
Key lesson: A service_role key with role: "service_role" is USELESS if the hostname doesn't resolve. Always verify the project ref FIRST.
Self-Hosted Storage (Tusky + MinIO) — Auth Debug
Architecture: Storage requests go through Kong to http://storage:5000/storage/v1/* which is the Tusky resumable upload service. MinIO runs separately on port 9000 for actual object storage.
Kong does NOT validate storage auth: The key-auth plugin is NOT enabled on storage routes. Kong passes the apikey header through to the storage container without validation. All auth validation happens inside the storage container itself.
Storage container validates JWT internally: The storage container (Tusky) checks the JWT signature using its own PGRST_JWT_SECRET env var. If this doesn't match the JWT_SECRET of the Supabase project, all storage requests fail with "Invalid Compact JWS" (HTTP 400).
Critical env vars in storage container:
To check storage container env:
`bash
docker exec deploy-vps-storage-1 env | grep -E 'JWT|SECRET|KEY'
`
Finding the JWT_SECRET (the critical missing piece):
`bash
Option 1: From Supabase Studio (if accessible)
http://VPS_IP:3001 → Settings → API → JWT_SECRET
Option 2: From postgres (if studio has it stored)
docker exec deploy-vps-db-1 psql -U postgres -d postgres -c \
"SELECT id, jwt_secret FROM supabase.projects;"
Option 3: From the auth container (GoTrue stores it)
docker exec deploy-vps-auth-1 env | grep JWT
`
Fixing storage auth mismatch — two approaches:
Option A (Recommended): Update storage container env vars to match current project
1. Get JWT_SECRET from Supabase Studio or postgres
2. Restart storage container with updated env:
`bash
docker stop deploy-vps-storage-1
Edit or recreate container with new env vars
docker run -d --name deploy-vps-storage-1 \
-e PGRST_JWT_SECRET=YOUR_JWT_SECRET \
-e ANON_KEY=YOUR_ANON_KEY \
-e SERVICE_KEY=YOUR_SERVICE_KEY \
-e DATABASE_URL=postgres://postgres:PASSWORD@db:5432/postgres \
# ... other env vars
`
Option B: Use Supabase Cloud storage instead (simpler, no local storage to manage)
Bucket must exist in storage.buckets table:
`sql
SELECT * FROM storage.buckets;
-- If 'documents' not found → INSERT it:
INSERT INTO storage.buckets (id, name, public) VALUES ('documents', 'documents', false);
`
Storage paths in code: Use storage_path column (not file_path). Format: clients/{client_id}/{filename}.
Pitfalls
1. Role ENUM cast: When using has_role() in policies, cast the role: has_role(auth.uid(), 'admin'::app_role)
2. SECURITY DEFINER functions: has_role must use SET search_path = public, auth — not just public — because uid() lives in the auth schema
3. auth.users vs public.users: The auth schema houses the actual users table — always reference auth.users(id)
4. ON CONFLICT DO NOTHING: Use when creating auto-profiles to handle race conditions on signup
5. Container naming prefixes: On VPS deployments, containers may use custom prefixes (deploy-vps-, root-) not the default app- prefix
6. Container env vars masked: docker inspect often shows * for passwords — check the actual running container's env or mount a shell to read them
7. PostgREST caches RLS: Always docker restart deploy-vps-rest-1 after policy changes
8. uid() is PostgREST, not PostgreSQL: Never available in direct psql connections
9. Trigger doesn't create user_roles: The on_auth_user_created trigger only creates profiles — user_roles must be inserted separately
10. Multi-column UNIQUE + NULL = problems: PostgreSQL allows multiple NULL rows in unique indexes — always verify constraints with pg_constraint
12. Storage JWT mismatch causes 400 "Invalid Compact JWS": The storage container's PGRST_JWT_SECRET must match the Supabase project's JWT_SECRET. If storage uses keys from project A but the app uses tokens from project B, all storage requests fail. Always get the JWT_SECRET from the same project whose anon/service keys are in the .env.
13. Kong key-auth plugin is inactive on storage routes: Do not trust Kong to validate storage API keys — it passes them through. Auth happens inside the storage container.
14. Storage uses local MinIO, not Supabase Cloud storage: The /storage/v1/ path routes to http://storage:5000 (Tusky), not to Supabase's cloud storage. These are separate systems.
16. Functions main/ directory missing → restart loop: Edge Runtime needs /home/deno/functions/main/index.ts (not just /home/deno/functions/manage-users/index.ts). See supabase-edge-runtime-vps-debug skill.
16. Multiple Supabase projects: Users may share service_role keys from project A while the deployed app uses project B. Always extract project ref from frontend bundle first.