Lef-F cuts Mike loose from the cloud

A complete self-hosted stack so a firm can run Mike on its own infrastructure with a single command.

infrastructurecompliance

Lef-F's fork replaces the cloud services Mike normally depends on - Supabase for the database and authentication layer, Cloudflare R2 for file storage - with open-source equivalents that run anywhere. The result is a one-command deploy: stand the whole thing up on a server you control, no third-party accounts required.

The work is deliberate and well-sequenced: a 1,500-line plan committed up front, then methodical build-out of secrets handling, storage bootstrap, ingress routing, and slimmed-down container images. A post-launch round of fixes ironed out the trickier glue - auth requests routed through the right hostnames, storage URLs that play nicely with standard S3 client libraries. It lands as a single pull request on a dedicated branch, keeping the cloud path intact for anyone who still wants it.

So what Firms with data-residency rules, air-gapped environments, or a policy against US cloud vendors now have a credible path to running Mike entirely in-house.

View this fork on GitHub →

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

Commits in this thread

21 commits from Lef-F/mike, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
4038fe74 docs: add self-host docker-compose spec and plan Lef 2026-05-04 ↗ GitHub
2a9edade feat: add generate-secrets.sh for docker-compose self-host stack Lef 2026-05-04 ↗ GitHub
6fe97e23 fix: harden generate-secrets.sh permissions, openssl guard, CRLF Lef 2026-05-04 ↗ GitHub
c027e2e6 feat: add root .env.example for docker-compose stack Lef 2026-05-04 ↗ GitHub
93152400 feat: add postgres init script for PostgREST roles and auth helpers Lef 2026-05-04 ↗ GitHub
ff85a408 feat: add garage single-node config for self-host stack Lef 2026-05-04 ↗ GitHub
cd1678ae feat: add init-db script for applying mike schema Lef 2026-05-04 ↗ GitHub
6e9c11f8 feat: add init-garage script for bucket and key bootstrap Lef 2026-05-04 ↗ GitHub
3a37c449 feat: add mike-backend Dockerfile with libreoffice Lef 2026-05-04 ↗ GitHub
09f6be77 feat: add mike-frontend Dockerfile Lef 2026-05-04 ↗ GitHub
2f46947f feat: add caddyfile ingress for self-host stack Lef 2026-05-04 ↗ GitHub
ee25d150 feat: add docker-compose for self-host stack Lef 2026-05-04 ↗ GitHub
9613b6bd fix: resolve first-boot smoke test failures for Task 11 Lef 2026-05-04 ↗ GitHub
commit body
- scripts/generate-secrets.sh: strip inline comments from .env values
  so GARAGE_RPC_SECRET (and others) are correctly detected as empty and
  regenerated on first run
- docker/init-garage.Dockerfile: new multi-stage image that copies
  /garage binary into alpine, replacing the shell-less scratch image
  that had no /bin/sh
- docker-compose.yml: change init-garage to use alpine:3.20 directly
  (garage CLI no longer needed; init script now calls HTTP admin API);
  add API_EXTERNAL_URL env var required by GoTrue v2
- docker/init-garage.sh: rewrite to use Garage v2 HTTP admin API
  (port 3903) instead of the RPC CLI, which required the server's node
  key; fixes both first-boot and idempotent restart; corrects endpoint
  names (GetKeyInfo, not GetKey; ListKeys for existence check)
8ff0dc8b docs: document docker-compose self-host workflow Lef 2026-05-05 ↗ GitHub
9cc8453c fix: route backend->gotrue through caddy with transparent host header Lef 2026-05-05 ↗ GitHub
commit body
The backend's @supabase/supabase-js client constructs URLs as
${SUPABASE_URL}/auth/v1/..., so SUPABASE_URL must point at a layer
that strips /auth/v1/ before forwarding to GoTrue (which serves at
the root path). Pointing it at gotrue:9999 directly returned 404
on every auth call, causing all backend endpoints to return 401.

Two changes:
- Caddy site address relaxed from {$MIKE_HOST}:{$MIKE_PORT} to
  :{$MIKE_PORT} so internal services (caddy:80) reach it without
  Host-header matching games.
- reverse_proxy directives set 'header_up Host {upstream_hostport}'
  so Caddy presents the upstream's address as the Host header instead
  of forwarding the inbound Host through, which was causing GoTrue
  to return empty 200 responses for in-cluster requests.
- mike-backend SUPABASE_URL now points at http://caddy:${MIKE_PORT}.
9f6dcfaf fix: align garage virtual-hosted-style routing for AWS S3 SDK Lef 2026-05-05 ↗ GitHub
commit body
The AWS S3 SDK uses virtual-hosted-style URLs by default
(<bucket>.<endpoint>), so it tries to resolve 'mike.garage:3900'
when uploading to the 'mike' bucket via http://garage:3900.

Three changes:
- docker-compose.yml: add a network alias 'mike.garage' on the
  garage service so the SDK's hostname resolves.
- garage.toml: set s3_region = 'auto' to match the value Mike's
  storage.ts hardcodes when constructing the S3 client (Garage
  rejects requests whose AWS4 scope doesn't match its region).
- garage.toml: set root_domain = '.garage' so Garage parses the
  bucket name out of the inbound Host header for virtual-hosted
  requests.

Tied to R2_BUCKET_NAME=mike. If you rename the bucket, update
the alias in docker-compose.yml to match.
8d411844 perf: use Next.js standalone output for frontend image Lef 2026-05-05 ↗ GitHub
commit body
Drops mike-frontend image from 1.98 GB to ~465 MB (76% reduction)
by relying on Next.js's standalone output mode. Standalone bundles
only the node_modules subset the server actually requires at runtime
instead of copying the whole build-stage tree (which still contains
devDeps after npm ci).

Two changes:
- frontend/next.config.ts: add output: 'standalone'.
- docker/frontend.Dockerfile: runtime stage now copies .next/standalone
  + .next/static + public, and runs 'node server.js' instead of
  'npm run start'.

The standalone server still picks up the NEXT_PUBLIC_* values that
were baked at build time, so behaviour is unchanged.
0549bff6 chore: silence compose warning when only one LLM key is set Lef 2026-05-05 ↗ GitHub
commit body
Adds the :- empty-default substitution to ANTHROPIC_API_KEY and
GEMINI_API_KEY so docker compose treats them as optional. The .env.example
default is the empty string but a user that wipes one of them (e.g. only
sets GEMINI_API_KEY) was getting a 'variable is not set' warning on every
compose invocation. Cosmetic, but loud enough to bury real warnings.
e59f59ea chore: drop unused MIKE_HOST from caddy + remove orphaned Dockerfile Lef 2026-05-05 ↗ GitHub
commit body
Two cleanups surfaced by the final code review:

- Caddy's site address was relaxed from {$MIKE_HOST}:{$MIKE_PORT} to
  :{$MIKE_PORT} in 9cc8453, but the compose service kept passing
  MIKE_HOST: ${MIKE_HOST} into the container env. The variable is no
  longer read by the Caddyfile; replace with a comment explaining
  where MIKE_HOST is actually consumed (frontend build args + GoTrue
  allow-list) so future maintainers don't assume it flows here.

- docker/init-garage.Dockerfile was created as part of 9613b6b's first
  bootstrap attempt; the same commit pivoted init-garage to use
  alpine:3.20 + Garage's HTTP admin API and the Dockerfile became
  unreferenced. Delete it to avoid misleading future edits.
9c7218a8 fix: add aud claim to minted JWTs in generate-secrets.sh Lef 2026-05-05 ↗ GitHub
commit body
PostgREST is configured with PGRST_JWT_AUD: authenticated. The anon
and service_role JWTs minted by generate-secrets.sh had no aud claim
at all. PostgREST currently accepts a missing aud as a soft-pass
under our config - the smoke test exercised this path and got 200 -
but that is brittle library behaviour and would change between
PostgREST major versions.

Add "aud":"authenticated" to the JWT payload so the audience check
is explicit. GoTrue-issued user JWTs already carry this claim, so
the publishable/secret JWTs now match the user-session shape.

HMAC signing is unchanged. Existing .env files keep working with
their current keys; users who want the new form run
./scripts/generate-secrets.sh --force.
325510e6 Merge pull request #1 from Lef-F/lef/docker-compose Lef Filippakis 2026-05-05 ↗ GitHub
Self-hosted docker-compose stack

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-41.md from inside the repo you want the changes in.

⬇ Download capture-thread-41.md