46 KiB
Supabase → Self-Hosted Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Move Route Commerce off Supabase's hosted platform onto a self-hosted stack (Postgres + PostgREST + MinIO + better-auth) while preserving all data and behavior. Consolidate the two in-flight branches into one coherent selfhost/migrate branch.
Architecture: Merge crispygoat/self-hosted-postgres (infra: docker-compose, Gitea deploy, env) and crispygoat/feat/better-auth (app: better-auth + S3 SDK + admin-permissions rewrite). Capture full schema + data from live Supabase via pg_dump. Apply preflight + captured schema + 137 migrations + RLS disable to a local Postgres 16 container. Stand up MinIO + PostgREST alongside. Keep supabase-js client (it speaks PostgREST natively) — only swap the base URL.
Tech Stack: Postgres 16 (Docker), PostgREST 12 (Docker), MinIO (Docker), better-auth (Node), Kysely + pg, AWS SDK for JavaScript v3, Next.js 16, Node 22.
Spec: docs/superpowers/specs/2026-06-05-selfhost-migration-design.md
Working assumptions:
- Working directory:
/home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44 - Supabase project ref:
wnzkhezyhnfzhkhiflrp(per CLAUDE.md / MEMORY.md) - Supabase DB password: in
.env.localon the user's Mac (not committed); passed via env var topg_dump - Docker is installed locally; user can run
docker compose - Direct PG connection to Supabase works from the user's Mac (not from this dev box, per MEMORY.md)
- Local Postgres:
127.0.0.1:5432; PostgREST:127.0.0.1:3001; MinIO API:127.0.0.1:9000; MinIO console:127.0.0.1:9001 - Local Postgres credentials: user
routecommerce, password from local.env
Phase 0 — Branch setup and merge
Task 0.1: Create the migration branch
Files: none (git only)
- Step 1: Fetch both source branches
cd /home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44
git fetch crispygoat
git fetch origin
Expected: crispygoat/self-hosted-postgres and crispygoat/feat/better-auth both updated.
- Step 2: Create and check out the new branch from main
git checkout -b selfhost/migrate main
Expected: Switched to a new branch 'selfhost/migrate'.
- Step 3: Verify branch state
git log --oneline -1
Expected: 9374e63 docs(memory): record Codex review round 2 pass.
Task 0.2: Cherry-pick the self-hosted-postgres branch
Files: cherry-picked from crispygoat/self-hosted-postgres
- Step 1: Cherry-pick the 7 commits in chronological order
git cherry-pick 2de32b0 988310f 8ad8dbb fddb917 beac3e4 e8f2d76 367a562
Expected: Either all commits apply cleanly, OR you see "CONFLICT" messages.
- Step 2: If conflicts appear, resolve them now
Expected conflicts: .env.example (will be merged with better-auth env in Task 0.3); .gitea/workflows/deploy.yml (updated in Task 0.5); docker-compose.yml (extended with PostgREST + MinIO in Task 0.4).
For each conflict:
- Open the file.
- Resolve by taking the union of both sides, with section comments.
git add <file>.git cherry-pick --continue.
- Step 3: Verify all 7 commits applied
git log --oneline -8
Expected: top 7 commits match the cherry-pick list, in order, with 9374e63 at position 8.
- Step 4: Verify docker-compose.yml exists and has the db service
grep -A2 "services:" docker-compose.yml
Expected: shows the db: service block (Postgres 16).
Task 0.3: Cherry-pick the feat/better-auth branch
Files: cherry-picked from crispygoat/feat/better-auth
- Step 1: Cherry-pick the single commit
git cherry-pick 456b5b1
Expected: Either applies cleanly, OR conflicts in .env.example, docker-compose.yml, or .gitea/workflows/deploy.yml.
- Step 2: Resolve conflicts if needed
Take the union of both sides. For .env.example, organize into sections:
# ── Postgres (self-hosted) ── (from self-hosted-postgres)
# ── PostgREST ── (NEW — added in Task 0.4)
# ── better-auth ── (from feat/better-auth)
# ── MinIO ── (NEW — added in Task 0.4)
# ── Supabase env vars (legacy) ── (modified to point to local PostgREST)
For each conflict: open the file, resolve by union, git add <file>, git cherry-pick --continue.
- Step 3: Verify the new files exist
ls -la src/lib/auth.ts src/lib/auth-client.ts src/lib/storage.ts \
src/app/api/auth/\[...all\]/route.ts \
supabase/migrations/000_preflight_supabase_compat.sql \
supabase/migrations/200_better_auth_tables.sql
Expected: all 6 files exist.
- Step 4: Verify the deleted migrations are gone
ls supabase/migrations/BUNDLE_018_042.sql \
supabase/migrations/087_brand_logos_bucket.sql \
supabase/migrations/099_contact_imports_bucket.sql \
supabase/migrations/145_create_product_images_bucket.sql 2>&1
Expected: all 4 files report "No such file or directory".
- Step 5: Verify
src/lib/supabase/server.tsis gone
ls src/lib/supabase/server.ts 2>&1
Expected: "No such file or directory".
Task 0.4: Add PostgREST + MinIO services to docker-compose.yml
Files:
-
Modify:
docker-compose.yml -
Step 1: Read current docker-compose.yml
cat docker-compose.yml
- Step 2: Append the PostgREST + MinIO + minio_init services
Append to docker-compose.yml (before the existing volumes: section if present, else at end):
postgrest:
image: postgrest/postgrest:v12.2.3
container_name: route_commerce_postgrest
restart: unless-stopped
env_file: [.env]
environment:
PGRST_SERVER_PORT: 3001
PGRST_SERVER_HOST: 0.0.0.0
PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PGRST_DB_ANON_ROLE: anon
PGRST_DB_SCHEMAS: public
PGRST_OPENAPI_MODE: disabled
PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET:-dev-secret-not-for-prod}
ports:
- "127.0.0.1:3001:3001"
depends_on:
db:
condition: service_healthy
minio:
image: minio/minio:latest
container_name: route_commerce_minio
restart: unless-stopped
env_file: [.env]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
minio_init:
image: minio/mc:latest
container_name: route_commerce_minio_init
depends_on:
minio:
condition: service_healthy
env_file: [.env]
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
for b in brand-logos product-images contacts-imports videos water-photos; do
mc mb --ignore-existing local/$${b}
mc anonymous set download local/$${b}
done
echo "MinIO buckets initialized"
- Step 3: Add
minio_datato the volumes section
Find the existing volumes: block and add minio_data:
volumes:
db_data:
driver: local
minio_data:
driver: local
- Step 4: Verify the YAML parses
docker compose config --quiet
Expected: exit code 0, no output.
- Step 5: Commit
git add docker-compose.yml
git commit -m "feat(docker): add PostgREST, MinIO, and minio_init services"
Task 0.5: Update Gitea deploy workflow to bring up Docker stack
Files:
-
Modify:
.gitea/workflows/deploy.yml -
Step 1: Read current deploy.yml
cat .gitea/workflows/deploy.yml
- Step 2: Add a "Start Docker stack" step before the existing "Deploy" step
Insert AFTER npm run build and BEFORE Deploy:
- name: Start Docker stack
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
cd /home/tyler/route-commerce
[ -f .env ] || cp .env.example .env
{
echo "POSTGRES_USER=${POSTGRES_USER}"
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "POSTGRES_DB=${POSTGRES_DB}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "DATABASE_URL=${DATABASE_URL}"
} >> .env
docker compose up -d db postgrest minio minio_init
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
- Step 3: Add a migration apply step
Insert AFTER Start Docker stack and BEFORE Deploy:
- name: Apply migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
run: |
cd /home/tyler/route-commerce
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping"
for f in supabase/migrations/[0-9]*.sql; do
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f"
done
- Step 4: Update the
Deploystep'senv:block
Add to the Deploy step's env: block (preserving the existing env entries):
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
- Step 5: Update the
printfblock in theDeploystep
Replace the existing printf block with:
{
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "BETTER_AUTH_SECRET=%s\n" "$BETTER_AUTH_SECRET"
printf "BETTER_AUTH_URL=%s\n" "$BETTER_AUTH_URL"
printf "NEXT_PUBLIC_BETTER_AUTH_URL=%s\n" "$NEXT_PUBLIC_BETTER_AUTH_URL"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
} > $APP_DIR/.env.production
- Step 6: Validate YAML syntax
python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))"
Expected: exit code 0, no output.
- Step 7: Commit
git add .gitea/workflows/deploy.yml
git commit -m "feat(deploy): bring up Docker stack + apply migrations in Gitea workflow"
Task 0.6: Fix useMockData to not trigger on non-Supabase URLs
Files:
-
Modify:
src/lib/supabase.ts:8 -
Step 1: Read the current line 8
sed -n '8p' src/lib/supabase.ts
Expected:
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co");
- Step 2: Replace the line
Replace:
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co");
With:
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl;
Rationale: with local PostgREST, the URL is http://localhost:3001. The previous check treated non-Supabase URLs as a "no backend" signal and forced mock mode.
- Step 3: Verify the change
sed -n '8p' src/lib/supabase.ts
Expected:
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl;
- Step 4: Commit
git add src/lib/supabase.ts
git commit -m "fix(supabase): don't force mock mode for non-Supabase URLs"
Task 0.7: Update package.json with new dependencies
Files:
-
Modify:
package.json -
Step 1: Read current package.json
cat package.json
- Step 2: Add dependencies (if not already added by the cherry-pick)
In the dependencies block, ensure these are present:
"@aws-sdk/client-s3": "^3.687.0",
"@aws-sdk/s3-request-presigner": "^3.687.0",
"better-auth": "^1.0.0",
"kysely": "^0.27.5",
"pg": "^8.13.1"
- Step 3: Install
npm install
Expected: no errors.
- Step 4: Verify packages are installed
ls node_modules/better-auth node_modules/@aws-sdk/client-s3 node_modules/kysely node_modules/pg 2>&1 | head -10
Expected: all 4 directories exist.
- Step 5: Commit
git add package.json package-lock.json
git commit -m "chore(deps): add better-auth, Kysely, pg, AWS SDK"
Task 0.8: Verify the merged build
Files: none (verification only)
- Step 1: Run type check
npx tsc --noEmit
Expected: exit code 0. If errors appear, fix inline (likely from feat/better-auth's src/lib/admin-permissions.ts rewrite).
- Step 2: Run lint
npm run lint
Expected: exit code 0.
- Step 3: Run build
npm run build
Expected: ✓ Compiled successfully.
- Step 4: Commit any fixes
git add -A
git diff --cached --quiet || git commit -m "fix(build): address merge issues in selfhost/migrate branch"
Phase A — Database schema capture and apply
Task A.1: Set up local Postgres via Docker
Files: none (docker only)
- Step 1: Create local
.envfrom.env.example
cp .env.example .env
- Step 2: Generate strong passwords and write to
.env
POSTGRES_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
MINIO_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
JWT_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48)
BETTER_AUTH_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48)
sed -i "s|change-me-strong-password|$POSTGRES_PW|g" .env
sed -i "s|change-me-minio-root|$MINIO_PW|g" .env
echo "POSTGREST_JWT_SECRET=$JWT_SECRET" >> .env
echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" >> .env
- Step 3: Verify
.envis updated
grep -E "^POSTGRES_PASSWORD|^MINIO_ROOT_PASSWORD|^POSTGREST_JWT_SECRET|^BETTER_AUTH_SECRET" .env
Expected: 4 non-empty lines, no change-me-... placeholders.
- Step 4: Start the db container
docker compose up -d db
Expected: Container route_commerce_db ... Started.
- Step 5: Wait for Postgres
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U routecommerce -d route_commerce > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
Expected: Postgres is ready within 60 seconds.
- Step 6: Verify connectivity
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT version();"
Expected: PostgreSQL 16.x ....
- Step 7: Do NOT commit
.env(gitignored).
Task A.2: Capture schema + data from live Supabase
Files:
- Create:
supabase/captured_schema.sql(committed to repo) - Create:
supabase/captured_data.sql(committed to repo)
NOTE: This step runs on the user's Mac (direct PG blocked from this dev box per MEMORY.md).
- Step 1: Get the Supabase DB password
Ask the user for the Supabase DB password (it should be in .env.local as SUPABASE_DB_PASSWORD or SUPABASE_SERVICE_ROLE_KEY).
- Step 2: Run pg_dump with retry (per spec's error handling)
SUPABASE_PW="<password from user>"
run_pg_dump_schema() {
pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \
--schema-only --no-owner --no-privileges \
--schema=public \
-f /tmp/captured_schema.sql
}
run_pg_dump_data() {
pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \
--data-only --no-owner --no-privileges \
--schema=public \
-f /tmp/captured_data.sql
}
if ! run_pg_dump_schema; then
echo "First schema dump failed; retrying once..."
sleep 5
if ! run_pg_dump_schema; then
echo "Schema dump failed twice; aborting. Do NOT proceed with a partial dump."
exit 1
fi
fi
if ! run_pg_dump_data; then
echo "First data dump failed; retrying once..."
sleep 5
if ! run_pg_dump_data; then
echo "Data dump failed twice; aborting."
exit 1
fi
fi
Expected: two files created, ~5-20MB schema + variable data.
- Step 3: Verify the dumps are non-trivial
wc -l /tmp/captured_schema.sql /tmp/captured_data.sql
Expected: thousands of lines each.
- Step 4: Copy the dumps into the repo
cp /tmp/captured_schema.sql supabase/captured_schema.sql
cp /tmp/captured_data.sql supabase/captured_data.sql
ls -la supabase/captured_*.sql
Expected: both files exist, > 1MB.
- Step 5: Sanity-check the schema dump
grep -c "CREATE TABLE" supabase/captured_schema.sql
grep -c "CREATE FUNCTION" supabase/captured_schema.sql
Expected: many CREATE TABLE (~60) and CREATE FUNCTION (~185) statements.
- Step 6: Commit the dumps
git add supabase/captured_schema.sql supabase/captured_data.sql
git commit -m "chore(supabase): capture schema + data dump from live Supabase"
Task A.3: Apply the preflight migration
Files: none (uses 000_preflight_supabase_compat.sql already in repo)
- Step 1: Apply 000_preflight_supabase_compat.sql
docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql
docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql
Expected: no errors; CREATE EXTENSION, CREATE ROLE, CREATE SCHEMA, CREATE OR REPLACE FUNCTION auth.uid() succeed.
- Step 2: Verify the preflight created the expected objects
docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dn"
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT rolname FROM pg_roles WHERE rolname IN ('anon', 'authenticated', 'service_role');"
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT proname FROM pg_proc WHERE pronamespace = 'auth'::regnamespace;"
Expected: auth schema exists; 3 roles exist; uid(), role(), email() functions exist.
- Step 3: Verify routecommerce can act as the stub roles
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SET ROLE anon; SELECT current_user; RESET ROLE;"
Expected: current_user reports anon; RESET ROLE works.
Task A.4: Apply the captured schema to local Postgres
Files: none
- Step 1: Apply the captured schema
docker compose cp supabase/captured_schema.sql db:/tmp/captured_schema.sql
docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/captured_schema.sql 2>&1 | tee /tmp/captured_schema_apply.log
Expected: many CREATE TABLE, CREATE FUNCTION messages. Some may fail (e.g., auth.uid() redefinition — preflight stub gets replaced by the real Supabase body). That's OK if no critical errors block table creation.
- Step 2: Re-apply the preflight to restore the stub auth.uid()
docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql
docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql
Expected: CREATE OR REPLACE FUNCTION succeeds.
- Step 3: Verify tables exist
docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | wc -l
Expected: > 60 (60+ tables in public).
- Step 4: Verify SECURITY DEFINER functions exist
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE prosecdef = true;"
Expected: ~185.
Task A.5: Patch the 3 broken migrations
Files:
-
Modify:
supabase/migrations/006_water_log_rpcs_fixed.sql -
Modify:
supabase/migrations/099_harvest_reach_segmentation.sql -
Modify:
supabase/migrations/135_email_automation_rpcs.sql -
Step 1: Patch 006 — replace
STATICwithSTABLE
sed -i 's/\bSTATIC\b/STABLE/g' supabase/migrations/006_water_log_rpcs_fixed.sql
grep -c "STABLE" supabase/migrations/006_water_log_rpcs_fixed.sql
Expected: 6+ occurrences of STABLE.
- Step 2: Patch 099 — quote the
timecolumn references
grep -n "time" supabase/migrations/099_harvest_reach_segmentation.sql
In the file, find references to the time column (a reserved word). Use search_replace to wrap in double quotes, matching the pattern from the existing 148 patch:
RETURNS TABLE ( ..., "time" TEXT, ... )
...
s."time",
- Step 3: Patch 135 — add a default to
p_next_email_atinenroll_abandoned_cart
grep -n "enroll_abandoned_cart" supabase/migrations/135_email_automation_rpcs.sql
Find the function signature. Add a default value to the p_next_email_at parameter:
CREATE OR REPLACE FUNCTION public.enroll_abandoned_cart(
p_brand_id UUID,
p_cart_id UUID,
p_next_email_at TIMESTAMPTZ DEFAULT now() + interval '1 hour'
)
RETURNS ...
(If the function doesn't have p_next_email_at, find the actual signature and apply a similar fix.)
- Step 4: Commit the patches
git add supabase/migrations/006_water_log_rpcs_fixed.sql \
supabase/migrations/099_harvest_reach_segmentation.sql \
supabase/migrations/135_email_automation_rpcs.sql
git commit -m "fix(migrations): patch 3 broken migrations (006, 099, 135)"
Task A.6: Apply all 137 migrations
Files:
-
Create:
scripts/apply-migrations.sh(committed) -
Step 1: Create the migration apply script
Create scripts/apply-migrations.sh:
#!/usr/bin/env bash
set -u
CONTAINER=route_commerce_db
DB=route_commerce
USER=routecommerce
LOG=/tmp/migration_apply.log
> $LOG
for f in supabase/migrations/[0-9]*.sql; do
echo "Applying $f ..." | tee -a $LOG
docker compose cp "$f" "$CONTAINER:/tmp/$(basename $f)"
if docker compose exec -T "$CONTAINER" psql -U "$USER" -d "$DB" -v ON_ERROR_STOP=0 -q -f "/tmp/$(basename $f)" >> $LOG 2>&1; then
echo " OK" | tee -a $LOG
else
echo " FAIL (continuing)" | tee -a $LOG
fi
done
echo "--- summary ---"
echo "Failures:"
grep -B1 "FAIL" $LOG | head -40
Make it executable:
chmod +x scripts/apply-migrations.sh
- Step 2: Run the migration apply
./scripts/apply-migrations.sh 2>&1 | tail -40
Expected: most migrations apply; some may fail (captured in log). The 3 patched ones (006, 099, 135) should succeed.
- Step 3: Verify which migrations failed
grep -B1 "FAIL" /tmp/migration_apply.log
Expected: a small list. If anything critical failed (e.g., 200_better_auth_tables.sql), investigate.
- Step 4: Verify the better-auth tables exist
docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | grep -E '"user"|"session"|"account"|"verification"'
Expected: 4 tables.
- Step 5: Commit the apply script
git add scripts/apply-migrations.sh
git commit -m "chore(scripts): add apply-migrations.sh for local Postgres"
Task A.7: Apply the data dump
Files: none (uses supabase/captured_data.sql from Task A.2)
- Step 1: Apply the data dump
docker compose cp supabase/captured_data.sql db:/tmp/captured_data.sql
docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 -f /tmp/captured_data.sql 2>&1 | tail -20
Expected: many INSERT statements. Some may fail (e.g., foreign key violations if migration order differs) — document in log.
- Step 2: Verify data fidelity (spot check)
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM brands;"
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM products;"
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM orders;"
Expected: counts > 0 (or 0 if the corresponding table is empty).
Task A.8: Disable RLS on all public tables
Files: none (SQL only)
- Step 1: Apply the RLS disable block
docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 <<'SQL'
DO $$ DECLARE r record; BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY';
END LOOP;
END $$;
SQL
Expected: no errors; runs in a few seconds.
- Step 2: Verify RLS is disabled
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = true;"
Expected: 0 rows.
Phase B — MinIO setup and asset migration
Task B.1: Start MinIO and verify buckets
Files: none
- Step 1: Start MinIO + minio_init
docker compose up -d minio minio_init
Expected: both containers start.
- Step 2: Wait for the init container to finish
docker compose wait minio_init
docker compose logs minio_init | tail -10
Expected: MinIO buckets initialized in logs.
- Step 3: Verify all 5 buckets exist
docker compose exec -T minio mc alias set local http://localhost:9000 routecommerce "$MINIO_ROOT_PASSWORD"
docker compose exec -T minio mc ls local/
Expected: 5 buckets: brand-logos/, contacts-imports/, product-images/, videos/, water-photos/.
- Step 4: Verify a bucket is publicly readable
docker compose exec -T minio mc anonymous get local/brand-logos
Expected: download.
- Step 5: Verify MinIO is accessible from the host
curl -I http://127.0.0.1:9000/minio/health/live
Expected: HTTP/1.1 200 OK.
Task B.2: Copy existing brand assets from Supabase Storage to MinIO
Files: none
- Step 1: Find the Tuxedo hero video URL
grep -A2 "videos" src/components/storefront/TuxedoVideoHero.tsx | head -20
The URL looks like:
https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/<filename>
- Step 2: Download the Tuxedo hero video
mkdir -p /tmp/minio-migration/videos
curl -o /tmp/minio-migration/videos/<filename> "<URL from Step 1>"
ls -la /tmp/minio-migration/videos/
Expected: file downloaded, > 0 bytes.
- Step 3: Find the brand logo URLs in
email-service.ts
grep -A1 "brand-logos" src/lib/email-service.ts | head -20
Four hardcoded URLs are expected.
- Step 4: Download the logos
mkdir -p /tmp/minio-migration/brand-logos/<brand_id>
for url in "<url1>" "<url2>" "<url3>" "<url4>"; do
fname=$(basename "$url")
curl -o "/tmp/minio-migration/brand-logos/<brand_id>/$fname" "$url"
done
ls -la /tmp/minio-migration/brand-logos/<brand_id>/
Expected: 4 files downloaded.
- Step 5: Find any other hardcoded Supabase storage URLs
grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co/storage/v1/object/public" src/ --include="*.ts" --include="*.tsx" | head -20
Download any URLs found to /tmp/minio-migration/misc/.
- Step 6: Upload the videos to MinIO
docker compose cp /tmp/minio-migration/videos minio:/tmp/videos
docker compose exec -T minio mc cp --recursive /tmp/videos/ local/videos/
docker compose exec -T minio mc ls local/videos/
Expected: video file(s) listed.
- Step 7: Upload the brand logos to MinIO
docker compose cp /tmp/minio-migration/brand-logos minio:/tmp/brand-logos
docker compose exec -T minio mc cp --recursive /tmp/brand-logos/ local/brand-logos/
docker compose exec -T minio mc ls local/brand-logos/ --recursive
Expected: logo files listed.
- Step 8: Verify the assets are publicly accessible
curl -I http://127.0.0.1:9000/videos/<filename>
curl -I http://127.0.0.1:9000/brand-logos/<brand_id>/<logo_filename>
Expected: HTTP/1.1 200 OK.
- Step 9: Clean up
rm -rf /tmp/minio-migration
Phase C — PostgREST + end-to-end local verification
Task C.1: Start PostgREST and verify
Files: none
- Step 1: Start the PostgREST service
docker compose up -d postgrest
Expected: Container route_commerce_postgrest ... Started.
- Step 2: Wait for PostgREST healthcheck
for i in $(seq 1 15); do
if curl -sf http://127.0.0.1:3001/ > /dev/null 2>&1; then
echo "PostgREST is up"
break
fi
sleep 2
done
Expected: PostgREST is up within 30 seconds.
- Step 3: Smoke test: list brands
curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon" | head -c 500
Expected: a JSON array.
- Step 4: Smoke test: call a SECURITY DEFINER RPC
curl -s -X POST "http://127.0.0.1:3001/rpc/get_public_stops_for_brand" \
-H "Content-Type: application/json" \
-H "apikey: local-anon" \
-d '{"p_slug":"indian-river-direct"}' | head -c 500
Expected: a JSON array (or [] if no data yet).
- Step 5: Check the PostgREST logs for errors
docker compose logs postgrest --tail=30
Expected: no critical errors.
Task C.2: Set up local env and verify app build
Files:
-
Create:
.env.local(gitignored) -
Step 1: Create
.env.local
PG_PW=$(grep "^POSTGRES_PASSWORD=" .env | cut -d= -f2-)
BA_S=$(grep "^BETTER_AUTH_SECRET=" .env | cut -d= -f2-)
MINIO_PW=$(grep "^MINIO_ROOT_PASSWORD=" .env | cut -d= -f2-)
cat > .env.local <<EOF
NEXT_PUBLIC_SUPABASE_URL=http://localhost:3001
NEXT_PUBLIC_SUPABASE_ANON_KEY=local-anon
DATABASE_URL=postgresql://routecommerce:${PG_PW}@127.0.0.1:5432/route_commerce?schema=public
BETTER_AUTH_SECRET=${BA_S}
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
STORAGE_ENDPOINT=http://127.0.0.1:9000
STORAGE_REGION=us-east-1
STORAGE_ACCESS_KEY=routecommerce
STORAGE_SECRET_KEY=${MINIO_PW}
STORAGE_BUCKET_PREFIX=
NEXT_PUBLIC_BASE_URL=http://localhost:3000
EOF
- Step 2: Verify
.env.localcontents
cat .env.local
Expected: all values populated, no <...> placeholders.
- Step 3: Run type check
npx tsc --noEmit
Expected: exit code 0.
- Step 4: Run build
npm run build
Expected: ✓ Compiled successfully.
- Step 5: Fix any errors
Common issues:
- Missing
pgtypes →npm install --save-dev @types/pg - better-auth import errors →
ls node_modules/better-auth - PostgREST call returning errors → check Task A.6 verification
Task C.3: Verify mock mode still works
Files: none
- Step 1: Start the dev server with mock mode
NEXT_PUBLIC_USE_MOCK_DATA=true npm run dev
Expected: dev server starts on http://localhost:3000.
- Step 2: Visit the homepage
Open http://localhost:3000 in a browser. Expected: page loads.
- Step 3: Set the dev session cookie
In the browser dev tools, set a cookie:
Name: dev_session
Value: platform_admin
Domain: localhost
Path: /
- Step 4: Reload
/admin
Expected: admin dashboard loads with mock data.
- Step 5: Stop the dev server
# Ctrl+C in the terminal
Task C.4: Verify dev mode bypass works against local Postgres
Files: none
- Step 1: Start the dev server with the real DB
unset NEXT_PUBLIC_USE_MOCK_DATA
npm run dev
- Step 2: Set the dev session cookie
Same as Task C.3 Step 3: dev_session=platform_admin.
- Step 3: Visit /admin
Expected: admin dashboard loads with real data from local Postgres.
- Step 4: Verify a known data point
Visit /admin/brands and confirm a known brand is listed.
- Step 5: Stop the dev server
# Ctrl+C
Task C.5: Verify auth round-trip with better-auth
Files: none
- Step 1: Start the dev server
npm run dev
- Step 2: Sign up via better-auth API
curl -X POST http://localhost:3000/api/auth/sign-up/email \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"testpassword123","name":"Test User"}'
Expected: 200 with a user object (or 400 if signup is disabled — adjust per the better-auth config).
- Step 3: Sign in
curl -X POST http://localhost:3000/api/auth/sign-in/email \
-H "Content-Type: application/json" \
-c /tmp/cookies.txt \
-d '{"email":"test@example.com","password":"testpassword123"}'
Expected: 200; /tmp/cookies.txt contains rc_session_token.
- Step 4: Use the session to call a protected endpoint
curl -X GET http://localhost:3000/admin \
-b /tmp/cookies.txt \
-o /dev/null -w "%{http_code}\n"
Expected: 200 (or 307 redirect).
- Step 5: Verify the user was created
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT id, email, name FROM \"user\";"
Expected: row with email = 'test@example.com'.
- Step 6: Clean up
docker compose exec -T db psql -U routecommerce -d route_commerce -c "DELETE FROM \"user\" WHERE email = 'test@example.com';"
Task C.6: Verify storage round-trip with MinIO
Files: none
- Step 1: Start the dev server
npm run dev
- Step 2: Sign in as a platform admin
Set dev_session=platform_admin cookie in the browser.
- Step 3: Upload a product image
Go to /admin/products. Edit a product. Upload a new product image (JPG/PNG).
- Step 4: Verify the image is in MinIO
docker compose exec -T minio mc ls local/product-images/ --recursive | head -10
Expected: the uploaded image appears.
- Step 5: Verify the image is publicly accessible
IMG_KEY=$(docker compose exec -T minio mc ls local/product-images/ --recursive | head -1 | awk '{print $NF}')
curl -I "http://127.0.0.1:9000/product-images/$IMG_KEY"
Expected: HTTP/1.1 200 OK.
- Step 6: Verify the image renders on the storefront
Visit /tuxedo/products/<product-slug> (or /indian-river-direct/products/<product-slug>). The image should display.
Phase D — Production cutover
Task D.1: Set up prod server with Docker stack
Files: none (server admin)
- Step 1: SSH to the prod server
ssh tyler@route.crispygoat.com
- Step 2: Pull the latest code on
main
cd /home/tyler/route-commerce
git pull origin main
Expected: latest selfhost/migrate branch commits (merge selfhost/migrate to main first if your flow requires it).
- Step 3: Verify Docker is installed
docker --version
docker compose version
Expected: Docker version 24.x and Docker Compose version v2.x (or docker-compose version 1.x).
- Step 4: Verify Gitea secrets are set
In https://git.crispygoat.com/tyler/route-commerce/settings/secrets, verify:
-
POSTGRES_USER=routecommerce -
POSTGRES_PASSWORD= (32+ char random) -
POSTGRES_DB=route_commerce -
MINIO_ROOT_USER=routecommerce -
MINIO_ROOT_PASSWORD= (32+ char random) -
POSTGREST_JWT_SECRET= (48+ char random) -
DATABASE_URL=postgresql://routecommerce:<pw>@db:5432/route_commerce?schema=public -
BETTER_AUTH_SECRET= (48+ char random) -
BETTER_AUTH_URL=https://route.crispygoat.com -
NEXT_PUBLIC_BETTER_AUTH_URL=https://route.crispygoat.com -
STORAGE_ENDPOINT=http://minio:9000 -
STORAGE_REGION=us-east-1 -
STORAGE_ACCESS_KEY=routecommerce -
STORAGE_SECRET_KEY= (same asMINIO_ROOT_PASSWORD) -
STORAGE_BUCKET_PREFIX= (empty) -
NEXT_PUBLIC_STORAGE_BASE_URL=https://route.crispygoat.com/storage(or your reverse proxy URL) -
PGRST_SERVER_PORT=3001 -
PGRST_DB_URI=postgresql://routecommerce:<pw>@db:5432/route_commerce -
PGRST_DB_ANON_ROLE=anon -
NEXT_PUBLIC_SUPABASE_URL=http://postgrest:3001 -
NEXT_PUBLIC_SUPABASE_ANON_KEY= (any string) -
STRIPE_SECRET_KEY= (existing) -
RESEND_API_KEY= (existing) -
MINIMAX_API_KEY= (existing) -
MINIMAX_BASE_URL= (existing) -
Step 5: Push to trigger the Gitea workflow
From the dev box:
git push crispygoat selfhost/migrate:main
(Or merge selfhost/migrate to main first, then push main.)
- Step 6: Watch the Gitea Actions run
Open https://git.crispygoat.com/tyler/route-commerce/actions. Expected: Start Docker stack succeeds, Apply migrations runs through 137 migrations, Deploy restarts the app.
Task D.2: Verify prod is working
Files: none
- Step 1: Visit the homepage
Open https://route.crispygoat.com. Expected: page loads.
- Step 2: Visit a brand storefront
Open https://route.crispygoat.com/tuxedo and https://route.crispygoat.com/indian-river-direct. Expected: pages load, brand logos display from MinIO.
- Step 3: Sign in to the admin
Open https://route.crispygoat.com/login and sign in with a known admin account.
- Step 4: Verify data is present
Navigate /admin/orders, /admin/products, /admin/stops, /admin/communications. Verify data is from the dump (or fresh if no dump was applied).
- Step 5: Upload a new product image
In /admin/products, upload a new image. Verify it lands in MinIO and renders on the storefront.
- Step 6: Check prod logs for errors
ssh tyler@route.crispygoat.com
pm2 logs route-commerce --lines 100
docker compose logs db postgrest minio --tail=30
Expected: no critical errors.
Task D.3: 24h monitoring
Files: none
- Step 1: Monitor for 24 hours
Watch for:
-
Error rates in the Next.js app
-
Postgres connection issues
-
MinIO disk usage
-
PostgREST 5xx responses
-
Step 2: Roll back if critical issues appear
If anything goes wrong:
git revertthe merge commit onmain.pm2 restart route-commerce.- Investigate before re-attempting.
- Step 3: After 24h of stable operation, deactivate Supabase
In the Supabase dashboard:
- Pause the project (or delete, if certain).
- Cancel any active subscriptions.
- Note the deactivation date in
MEMORY.md.
- Step 4: Document the cutover in MEMORY.md
Append to MEMORY.md:
## Self-Hosted Cutover (YYYY-MM-DD)
- Migrated from Supabase to self-hosted Postgres + PostgREST + MinIO + better-auth.
- Branch: `selfhost/migrate` (merged to `main`).
- Schema dump: `supabase/captured_schema.sql` (commit <hash>).
- Data dump: `supabase/captured_data.sql` (commit <hash>) — or "no data dump, fresh start" if not applied.
- 24h monitoring: passed.
- Supabase project: paused/deleted on YYYY-MM-DD.
- Step 5: Final commit
git add MEMORY.md
git commit -m "docs(memory): record self-hosted cutover"
Phase E — Final verification (after cutover)
Task E.1: Run Playwright E2E tests (if available)
Files:
-
Modify:
playwright.config.ts(if needed for env) -
Step 1: Check if Playwright tests exist
ls tests/ 2>&1 | head -10
cat playwright.config.ts | head -40
- Step 2: Update
playwright.config.tsto use the local env (if needed)
webServer: {
command: 'npm run dev',
env: {
NEXT_PUBLIC_SUPABASE_URL: 'http://localhost:3001',
NEXT_PUBLIC_SUPABASE_ANON_KEY: 'local-anon',
DATABASE_URL: process.env.DATABASE_URL,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
NEXT_PUBLIC_STORAGE_BASE_URL: 'http://localhost:9000',
STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
STORAGE_ACCESS_KEY: 'routecommerce',
STORAGE_SECRET_KEY: process.env.STORAGE_SECRET_KEY,
},
url: 'http://localhost:3000',
timeout: 120_000,
},
- Step 3: Install Playwright browsers
npx playwright install --with-deps chromium
- Step 4: Run the tests
npx playwright test
Expected: tests pass (or skip if no tests exist). If tests fail, document the failures but don't block the migration.
- Step 5: Note the result
echo "Playwright result: $(date)" >> docs/superpowers/migration-e2e-result.log
echo "Pass/Fail: <result>" >> docs/superpowers/migration-e2e-result.log
Task E.2: Final cross-check against spec's acceptance criteria
Files: none
- Step 1: Verify the spec's acceptance criteria
git rev-parse --abbrev-ref HEAD
npx tsc --noEmit && echo "TSC OK" && npm run build && echo "BUILD OK"
Expected: selfhost/migrate, TSC OK, BUILD OK.
- Step 2: Check all 3 patched migrations applied
docker compose exec -T db psql -U routecommerce -d route_commerce -c "\df" | grep -E "verify_water_pin|enroll_abandoned_cart" | head -5
Expected: function names from 006 and 135 visible.
- Step 3: Check supabase-js works against PostgREST
curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon"
Expected: 200 with JSON array.
- Step 4: Check better-auth tables exist
docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) AS user_count FROM \"user\";"
Expected: a count.
- Step 5: Check MinIO buckets exist and are public
docker compose exec -T minio mc ls local/ | wc -l
Expected: 5.
- Step 6: Check env vars don't reference Supabase (except the legacy vars)
grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co" src/ --include="*.ts" --include="*.tsx" | head -10
Expected: only references in the old Supabase storage URLs (already migrated to MinIO). If any non-storage references remain, fix them.
- Step 7: Note the result in the spec
Add a status note to the spec's "Acceptance Criteria" section in docs/superpowers/specs/2026-06-05-selfhost-migration-design.md and commit.
Out of scope (deferred to follow-up plans)
- Migrating user passwords from
auth.userstouser.password(better-auth). Users will need to reset passwords. - Performance tuning (indexes, connection pooling, query optimization).
- Replacing
supabase-js(still used for PostgREST RPC calls; works fine). - Setting up a Caddy reverse proxy in front of MinIO (deferred; current setup exposes MinIO on port 9000 directly).
- Self-hosted email/SMS sending (Resend + Twilio still used as third-party services).
- Backup/restore strategy for the new Postgres (currently relies on Docker volume; consider adding
pg_dumpcron).