AUTH_MODE=local: self-hosted Postgres, JWT auth, and Docker Compose as a Supabase-free option

rglauco adds a runtime flag that swaps Supabase for a self-hosted Postgres instance with JWT auth and a full Docker Compose stack. The existing route layer stays untouched. Getting it to actually work took five follow-up patches.

infrastructuresecurity

The main commit (b8efde9f) introduces AUTH_MODE=local and a PgAdapter class at backend/src/lib/pgAdapter.ts (358 lines) that mimics the Supabase client query interface over pg.Pool. A DbClient abstraction in db.ts lets every existing Express route work in either mode without modification. New local auth endpoints (/auth/login, /auth/register) land in localAuth.ts. The Docker side is a multi-stage Node 22 Dockerfile for the backend (with LibreOffice for DOCX conversion), a frontend Dockerfile, and a compose.yml with named volumes and a Postgres health check. The frontend gets a local JWT path in AuthContext and a localAuth.ts helper.

Five bugs surfaced in live testing and were patched in the days following the initial commit. The frontend crashed entirely when NEXT_PUBLIC_SUPABASE_URL was missing because createClient("", "") throws - fixed by introducing a getSessionToken() helper across 7 components and hooks. AuthContext read localStorage once on mount, so successful sign-in immediately redirected back to /login - fixed with a refreshAuth callback. PgAdapter.maybeSingle() was returning arrays rather than single rows, breaking every POST /chat. JS arrays bound to jsonb columns serialized as {a,b} PostgreSQL array literals rather than valid JSON. Bare strings in jsonb content columns weren't JSON-encoded before insert. An upsert with only conflict-key columns generated invalid SQL (DO UPDATE SET with nothing to set). An Ollama availability check tested apiKeys.ollama !== undefined, which was always true since the object initializes ollama: null.

The PgAdapter parity gaps are the main thing to audit before adopting. The bugs that shipped suggest the adapter was written against a partial mental model of the Supabase client and not tested end-to-end. Specific areas to verify: all .single() and .maybeSingle() call sites, array filter operations, and any upsert paths with composite conflict keys.

So what Worth importing as a unit if you want Supabase-free self-hosted deployment - take the full patch set through 27fa98f6, since the initial commit shipped with several bugs that the follow-up patches correct. The Docker infrastructure is ready to use. Before pulling into production, audit the full `PgAdapter` surface against the Supabase calls in your codebase; the fix history suggests there are likely more parity gaps than the ones already caught.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

8 commits from rglauco/mike, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
b8efde9f feat: add local PostgreSQL mode and Docker Compose setup Glauco 2026-05-10 ↗ GitHub
commit body
Adds an optional AUTH_MODE=local that replaces Supabase with:
- Local PostgreSQL (via pg) for all data storage
- Custom JWT auth (bcrypt + jsonwebtoken) via /auth/login and /auth/register
- PgAdapter: a thin Supabase-compatible query builder over pg.Pool
- DbClient abstraction so all route files work in both modes unchanged

Infrastructure:
- backend/Dockerfile and frontend/Dockerfile for containerised builds
- compose.yml: frontend + backend + postgres with named volumes for persistence
- docker/postgres/init.sql: full schema without Supabase-specific auth tables
- .env.example at repo root with compose variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9768aef9 fix: frontend Dockerfile uses --legacy-peer-deps for npm ci Glauco 2026-05-10 ↗ GitHub
@opennextjs/cloudflare peer dep constraint doesn't exactly match the
pinned next@16.0.3, causing npm ci to fail without the flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1b395c20 fix: refresh AuthContext after local sign-in/sign-up Glauco 2026-05-10 ↗ GitHub
commit body
AuthContext reads localStorage once on mount. After localSignIn() stores
the JWT, the context still had user=null, so the layout redirected back
to /login.

Fix: expose refreshAuth() in the context and call it immediately after
localSignIn/localSignUp so the state is updated before router.push().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
139ce362 fix: prevent Supabase client crash in local auth mode Glauco 2026-05-10 ↗ GitHub
commit body
createClient("", "") throws when NEXT_PUBLIC_SUPABASE_URL is not set,
crashing any page that imports supabase. Two fixes:

1. supabase.ts: use dummy localhost URL when Supabase is not configured
   so the module loads safely; add getSessionToken() that returns the
   local JWT or Supabase session token based on NEXT_PUBLIC_AUTH_MODE.

2. Replace all supabase.auth.getSession() calls in hooks and components
   with getSessionToken() so they work in both auth modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
88fd998d fix: pgAdapter maybeSingle, jsonb serialization for chat messages Glauco 2026-05-10 ↗ GitHub
commit body
Three bugs in local/Docker mode:

1. PgAdapter.maybeSingle() returned the full rows array instead of a
   single row because _run() only checked _singleMode, not
   _maybeSingleMode. All routes using maybeSingle() got an array back
   and treated it as "not found" → "Chat not found" on every POST /chat.

2. JavaScript arrays passed to pg as query parameters are formatted as
   PostgreSQL array literals {a,b} which is invalid for jsonb columns.
   Fixed in PgAdapter insert/update to JSON.stringify arrays before
   binding.

3. Plain strings inserted into jsonb columns (chat_messages.content for
   user messages) are not valid JSON, causing the INSERT to fail
   silently. Fixed by JSON.stringify-ing string content in chat.ts,
   projectChat.ts and tabular.ts before the INSERT.

Also added missing `workflow jsonb` column to chat_messages in both
init.sql and schema.sql - the backend was already inserting this field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b92cbf52 fix: expose postgres port 5432 in compose for local debugging Glauco 2026-05-10 ↗ GitHub
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
27fa98f6 fix: upsert DO NOTHING when updateCols empty, title model uses tabular model for Ollama Glauco 2026-05-10 ↗ GitHub
commit body
Two bugs:

1. PgAdapter upsert with only the conflict column (e.g. upsert({ user_id })
   on conflict user_id) produced "DO UPDATE SET " with an empty SET
   clause - invalid SQL. Now falls through to DO NOTHING when updateCols
   is empty, and supports the Supabase ignoreDuplicates:true option.
   This fixed "syntax error at end of input" on ensureProfileRow calls.

2. resolveTitleModel checked "apiKeys.ollama !== undefined" to detect
   Ollama, but the apiKeys object always initialises ollama: null (not
   undefined), so the condition was always true - every user with no cloud
   keys got title_model = local-gemma-26b which doesn't exist on Ollama.
   Fixed to check hasEnvOllama() || key.trim(), and when Ollama is the
   provider, reuse the user's configured tabular model (which they know is
   installed) instead of a hardcoded default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d9d8c574 Merge pull request #1 from rglauco/feat/docker-local-postgres Glauco 2026-05-10 ↗ GitHub
Feat/docker local postgres

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-220.md from inside the repo you want the changes in.

⬇ Download capture-thread-220.md