Self-hosted docker-compose stack

✅ merged · #1 · Lef-F/mike ← Lef-F/mike · opened 21d ago by Lef-F · merged 21d ago by Lef-F · self · +2,227-2 across 17 files · ↗ on GitHub

From the PR description

Summary

Adds a one-command docker-compose stack so this fork can be cloned and run locally with docker compose up -d --build. Targets a single trusted host (laptop / single VPS); explicitly defers TLS, real SMTP, multi-node Garage, and secrets-manager integration as additive future changes.

Why

The upstream README documents a bare-metal dev path that needs Supabase, Cloudflare R2, and a Resend account before you can run anything. That's the right shape for a SaaS deploy, but it forces every contributor and self-hoster to provision four external services to even see Mike's UI. This branch trades those for self-contained equivalents that fit on one host.

How - stack shape

Single docker-compose.yml brings up:

Service Image Role
caddy caddy:2-alpine Single ingress on ${MIKE_PORT:-80}, path-routes /auth/v1/* → GoTrue, /rest/v1/* → PostgREST, /backend/* → Mike API, everything else → Mike UI
postgres postgres:16-alpine Database
gotrue supabase/gotrue:v2.166.0 Authentication; issues + verifies the JWTs Mike's backend already validates
postgrest postgrest/postgrest:v12.2.3 REST layer - frontend uses it for direct user_profiles reads/writes
garage dxflrs/garage:v2.3.0 Single-node S3-compatible storage for documents (replication_factor = 1)
mike-backend built locally Express API + libreoffice-writer for DOCX→PDF
mike-frontend built locally Next.js 16 standalone build (~465 MB image)
init-db (one-shot) postgres:16-alpine Applies backend/migrations/000_one_shot_schema.sql after GoTrue is healthy
init-garage (one-shot) alpine:3.20 + curl Bootstraps Garage cluster layout, creates the mike bucket and access key, writes credentials to a docker volume that mike-backend mounts read-only

Only Caddy publishes a host port. Garage S3 (3900), RPC (3901), admin (3903), Postgres (5432), GoTrue (9999), PostgREST (3000), and the Mike images stay internal.

The full design rationale is in docs/superpowers/specs/2026-05-02-self-host-docker-compose-design.md; the per-task plan is in docs/superpowers/plans/2026-05-02-self-host-docker-compose.md.

How - secrets

scripts/generate-secrets.sh (POSIX shell + openssl, no Node/Python) populates .env from .env.example:

  • POSTGRES_PASSWORD, AUTHENTICATOR_PASSWORD - random hex
  • JWT_SECRET - random hex
  • SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY - HS256 JWTs (anon + service_role) signed with JWT_SECRET, with aud:authenticated so PostgREST's audience check passes explicitly
  • GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN - random hex

Garage's R2 access key/secret never live in .env at all - they're generated by the init-garage container on first boot and surfaced to mike-backend via a docker volume + entrypoint script. umask 077 + chmod 600 keep .env 600 throughout.

How - bringing it up

git clone <fork>
cd mike
cp .env.example .env
./scripts/generate-secrets.sh
$EDITOR .env                  # set ANTHROPIC_API_KEY and/or GEMINI_API_KEY
docker compose up -d --build  # ~5 min first time
open http://localhost

MIKE_PORT is configurable; changing it requires docker compose build mike-frontend because Next.js bakes URLs at build time.

What's in backend/ and frontend/

The core of the change is pure infra. Three small surgical edits to upstream Mike source landed during smoke testing and are kept as separate commits so they can be cherry-picked upstream independently:

  • backend/src/{routes/projects.ts,lib/access.ts} (commit 653d055) - .contains("shared_with", [array]) serializes as PostgreSQL array literal {a,b}, which is invalid input for jsonb. Two of three call sites passed bare arrays, causing every GET /projects to 500 on vanilla Postgres+PostgREST with invalid input syntax for type json. The third site (tabular.ts:107) already used JSON.stringify([...]); this commit aligns the other two with it.
  • frontend/next.config.ts (commit 8d41184) - adds output: "standalone". Combined with a Dockerfile rewrite to copy only .next/standalone + .next/static + public, this drops the frontend image from 1.98 GB to ~465 MB (76% reduction).
  • scripts/generate-secrets.sh JWT aud claim (commit 9c7218a) - explicit aud:authenticated on minted JWTs so PostgREST's PGRST_JWT_AUD check is unambiguous.

backend/migrations/000_one_shot_schema.sql is unchanged.

What's deliberately not included

  • TLS (laptop default is HTTP-only; TLS is a Caddyfile two-line change once a domain is in play)
  • Real SMTP (GOTRUE_MAILER_AUTOCONFIRM=true; flip + add GOTRUE_SMTP_* env to enable)
  • Multi-node Garage / cross-region replication
  • Secrets-manager integration (.env + docker volume only)
  • Backups for Postgres or Garage
  • CI / build pipeline changes
  • Removal of upstream's bare-metal dev path (backend/.env.example and frontend/.env.local.example stay intact)

Verification

A clean-clone smoke test runs through:

  1. docker compose up -d --build brings every service to healthy
  2. http://localhost/ serves the UI; signup creates a row in auth.users and public.user_profiles
  3. /auth/v1/health, /rest/v1/user_profiles?select=id&limit=1, /backend/health all 200 through Caddy
  4. Chat with Gemini streams back end-to-end (frontend → Caddy → backend → Gemini)
  5. PDF upload lands in Garage at documents/<userId>/<docId>/source.pdf, verified via ListObjectsV2
  6. docker compose down && docker compose up -d brings everything back healthy without rerunning migrations or recreating the bucket (init paths are idempotent)

Test plan

  • cp .env.example .env && ./scripts/generate-secrets.sh && docker compose up -d --build reaches all-healthy on a clean checkout
  • http://localhost/signup creates a user; http://localhost/account/models accepts an LLM key
  • Chat with Gemini or Anthropic returns a streamed response
  • DOCX or PDF upload succeeds and appears in the document list
  • docker compose down && docker compose up -d returns to healthy without manual intervention
  • docker compose down -v && docker compose up -d --build (full reset) reaches first-time-clean state

Our analysis

Backend jsonb shared_with contains() fix — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (21)

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.
653d0550 fix(backend): use JSON-array form of contains() for jsonb shared_with Lef 2026-05-05 ↗ GitHub
commit body
PostgrestFilterBuilder's .contains(column, value) serializes a JS
array as PostgreSQL array literal '{a,b}', which is the right shape
for text[] columns but invalid input for jsonb. Two of three call
sites (projects.ts and access.ts) passed bare arrays, causing every
GET /projects request to return 500 with 'invalid input syntax for
type json' on vanilla Postgres+PostgREST. The third site
(tabular.ts:107) already uses JSON.stringify([...]) - this commit
makes the other two match.

Tested by tracing the generated PostgREST URL: bare array produces
'cs.{smoke@test.local}' (4xx), JSON.stringify produces
'cs.["smoke@test.local"]' (200).
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.

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-1.md from inside the repo you want the changes in.

⬇ Download capture-pull-1.md