Skip to content

feat: self-host and air-gap stack — embedded Supabase, one-step Docker, AIRGAPPED mode (ADR) - #36

Open
amal66 wants to merge 1 commit into
upstream-mainfrom
upstream-pr/self-host-airgap
Open

feat: self-host and air-gap stack — embedded Supabase, one-step Docker, AIRGAPPED mode (ADR)#36
amal66 wants to merge 1 commit into
upstream-mainfrom
upstream-pr/self-host-airgap

Conversation

@amal66

@amal66 amal66 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Design (ADR)

Context. Mike's default deployment leans on external services: hosted Supabase (auth + Postgres + PostgREST), Cloudflare R2, cloud LLM APIs, and Resend. Legal teams routinely handle documents that must not leave their network; some operate fully disconnected ("air-gapped") environments. Upstream's CONTRIBUTING defers local-hosting refactors of the main app to a future fully-local version, and community PRs Open-Legal-Products#196 and Open-Legal-Products#149 (on Open-Legal-Products/mike) already explore Docker-based self-hosting. This PR ports the fork's production self-host/air-gap stack in a way that is strictly opt-in: nothing changes for a deployment that doesn't set AIRGAPPED=true or use the new compose files.

Decision.

  • Embedded Supabase, trimmed to 3 services, vs. external/hosted Supabase or the full CLI stack. The app only touches Postgres, GoTrue, and PostgREST. Those three run as pinned containers behind an nginx gateway (airgapped/gateway.conf) that routes the two prefixes supabase-js uses (/auth/v1, /rest/v1). Kong/Realtime/Storage/Studio/edge functions are dropped — the security boundary is the JWT + deny-all RLS, not a gateway apikey gate.
  • Air-gap enforcement in code, not just in the network. AIRGAPPED=true gates every egress channel the backend owns: cloud LLM dispatch is refused with an attributed ModelUnavailableError before any request leaves; CourtListener and user MCP connector tools are stripped from the chat tool set; and a boot guard (assertSecretsHardened) refuses to start on the Supabase demo JWT secret, demo-issuer keys, keys not signed by JWT_SECRET, or placeholder/short encryption secrets. Network isolation is the other half; the code half means an imperfect network setup still can't silently leak content.
  • Secrets are generated, never defaulted. gen-secrets.sh mints a CSPRNG JWT secret and derives the anon/service_role JWTs from it so GoTrue, PostgREST, and supabase-js agree; the compose uses ${VAR:?} so a missing secret fails the boot loudly instead of falling back to demo values.
  • Idempotent, ledgered migrations without the Supabase CLI/dashboard. backend/scripts/migrate.mjs applies ordered SQL under a Postgres advisory lock, records each file in a schema_migrations ledger with a checksum, no-ops on re-run, and fails on checksum drift. Per backend/schema.sql's own header, a fresh database takes schema.sql (staged in the air-gapped image as the lexically-first baseline "migration"), not the dated incremental files.
  • Sneakernet lifecycle. bundle.sh (connected host) pulls pinned images + builds app images into a checksummed tarball; install.sh (air-gapped host) verifies, loads, generates secrets once, and boots in dependency order; backup.sh/restore.sh capture the three stateful stores and the secrets (encrypted data is undecryptable without them); acceptance.sh runs the executable checks and lists the operator-side ones honestly.

Consequences. A one-step docker compose up dev stack (API + web hot-reload + MinIO + Redis) and a fully offline production stack become possible. Cost: this repo now carries compose files, two Dockerfiles, and pinned third-party image tags that need a re-bundle cadence (documented in airgapped/OPERATIONS.md). On this branch alone, AIRGAPPED=true refuses all chat models — local model serving (Ollama) arrives with the provider-registry/local-LLM PRs, whose env vars (ENABLE_OLLAMA, OPENAI_BASE_URL, AIRGAP_DEFAULT_MODEL) gen-secrets.sh already emits.

Alternatives considered.

Summary

Ports the fork's self-host and air-gap stack: a one-step Docker dev environment, an embedded-Supabase production stack for disconnected networks, generated (never demo) secrets with a boot guard, an idempotent migration runner, and AIRGAPPED=true egress gating in the backend.

Changes

  • docker-compose.yml — local dev stack: api (tsx hot-reload) + web (Next dev) + MinIO + Redis, with named node_modules volumes.
  • backend/Dockerfile, frontend/Dockerfile, per-dir .dockerignore — dev / build / production targets; production-airgapped adds LibreOffice (+fonts) for offline DOCX→PDF and stages schema.sql + the migration runner.
  • backend/scripts/migrate.mjs — ledgered, advisory-locked, checksum-verified SQL runner (new dep: pg, which the fork ships).
  • airgapped/docker-compose.airgapped.yml (Postgres + db-init + GoTrue + migrate + PostgREST + nginx gateway + MinIO + Redis + Mailpit + Ollama + api + web, only the gateway host-published), gateway.conf, Caddyfile (internal-CA TLS front), gen-secrets.sh, bundle.sh, install.sh, backup.sh, restore.sh, acceptance.sh, README.md, OPERATIONS.md, test override.
  • Backend gating: lib/airgap.ts (isAirgapped()), lib/secretGuard.ts + boot call in index.ts, cloud-LLM dispatch refusal in lib/llm/index.ts, CourtListener/MCP tool stripping in lib/chat/streaming.ts.
  • Tests: lib/__tests__/secretGuard.test.ts, __tests__/integration/stack.supabase.test.ts (env-gated stack E2E: auth contract, RLS deny-all, tenant isolation, leak sweep over all 24 public tables).
  • .gitignore: never commit airgapped/.env.generated; tsconfig.json: exclude tests from the production build (same hunk as the local-LLM PR).

Why

Law firms and courts have data that cannot leave the building; several will only evaluate Mike if it runs entirely on their hardware. Upstream's CONTRIBUTING defers local-hosting refactors to a future fully-local version — this PR is shaped for that constraint: every file is opt-in (new compose files, a new airgapped/ directory, code paths dead unless AIRGAPPED=true), and a non-Docker deployment is byte-for-byte unaffected at runtime. It is fully self-contained — nothing in it depends on any other PR, open or closed; it ships the complete stack on its own. It is also deliberately non-competing with Open-Legal-Products#196 and Open-Legal-Products#149 — see the Alternatives section; the offer to reconcile with those PRs' authors is genuine, and the air-gap layer here composes with either of their compose approaches.

Testing

  • backend: npm install + npm run build (tsc) — green as committed.
  • Vitest (harness from the upstream-pr/test-harness branch installed locally, not committed): secretGuard.test.ts 9/9 passed; stack.supabase.test.ts 4 skipped as designed (env-gated — it runs against a live stack via acceptance.sh / the README verify flow).
  • Static checks on the ops surface: gen-secrets.sh runs and emits a consistent derived-JWT env; docker compose config validates both docker-compose.airgapped.yml (+ test override, with generated secrets) and the root docker-compose.yml; bash -n on all six scripts; node --check on migrate.mjs.
  • Not verified here: actually booting the Docker/air-gapped stack or building the images end-to-end — stated honestly; the fork this is ported from runs this stack in production, and airgapped/README.md records its live verification (fresh volume → auth healthy, baseline applies idempotently, stack-E2E 4/4).

Provenance

All added lines are mechanical ports of amal66/mike@origin/main (b3166dd); exceptions (all layout translations or upstream-compat renames, no new logic):

  • Path/layout translation throughout: apps/apibackend, apps/webfrontend; the fork's workspace-based Dockerfiles (root-context npm ci --workspace=…) became standalone per-directory Dockerfiles, and the fork's single root .dockerignore became per-directory ones.
  • Migration source translation: the fork stages supabase/migrations/ (its squashed baseline); upstream keeps the baseline in backend/schema.sql, so the air-gapped image stages schema.sql as 00000000000000_baseline.sql and migrate.mjs's default dir/comments were adjusted accordingly.
  • secretGuard.ts: enforcement narrowed to AIRGAPPED=true only — the fork also enforces on NODE_ENV=production, which could break existing upstream cloud deployments (e.g. MCP_CONNECTORS_ENCRYPTION_SECRET is legitimately optional upstream); one test assertion added to pin the narrowed behavior.
  • Cloud-LLM refusal applied at upstream's static dispatcher (llm/index.ts): the fork enforces the same rule via its provider registry (cloud providers not registered + assertModelAvailable); ModelUnavailableError and its message are copied from the fork.
  • gen-secrets.sh: NEXT_PUBLIC_SUPABASE_ANON_KEYNEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY, R2_BUCKETR2_BUCKET_NAME (upstream's variable names).
  • stack.supabase.test.ts: PUBLIC_TABLES regenerated from upstream's schema.sql (adds library_folders).
  • backend/tsconfig.json test exclusion — identical to the hunk on the local-LLM branch.
  • New dependency pg@^8.22.0 (a dep the fork ships) + lockfile update.
  • Doc adaptations in airgapped/README.md / OPERATIONS.md: verify-flow paths, "Web CDN egress removed" downgraded from ✅ to ⚠️ (the upstream frontend hasn't been audited), and a note that local model serving lands with the local-LLM PR.

Credits & prior art

(Expanding the Alternatives section above into explicit credit.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC

…r, AIRGAPPED mode

Port of the self-host/air-gap stack from amal66/mike@main, translated from the
fork's monorepo layout (apps/api, apps/web, supabase/migrations) to the upstream
backend/ + frontend/ layout:

- docker-compose.yml: local dev stack (api + web hot-reload, MinIO, Redis)
- backend/Dockerfile, frontend/Dockerfile (+ .dockerignore): dev/production/
  air-gapped image targets; the air-gapped api image adds LibreOffice for
  offline DOCX→PDF and stages backend/schema.sql as the migration baseline
- backend/scripts/migrate.mjs: idempotent, ledgered SQL migration runner
  (advisory lock, checksum drift detection) replacing the Supabase dashboard
  for offline installs
- airgapped/: embedded Supabase data plane (Postgres + GoTrue + PostgREST
  behind an nginx gateway), MinIO, Redis, Mailpit, Ollama; gen-secrets.sh
  (derived JWT triple), bundle/install/backup/restore/acceptance scripts,
  Caddy TLS front, runbook
- backend AIRGAPPED=true env gating: secret boot guard (refuses demo/
  placeholder secrets), cloud LLM dispatch refusal, CourtListener + user MCP
  tool stripping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant