Self-hosted docker-compose stack
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 hexJWT_SECRET- random hexSUPABASE_PUBLISHABLE_KEY,SUPABASE_SECRET_KEY- HS256 JWTs (anon + service_role) signed withJWT_SECRET, withaud:authenticatedso PostgREST's audience check passes explicitlyGARAGE_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}(commit653d055) -.contains("shared_with", [array])serializes as PostgreSQL array literal{a,b}, which is invalid input forjsonb. Two of three call sites passed bare arrays, causing everyGET /projectsto 500 on vanilla Postgres+PostgREST withinvalid input syntax for type json. The third site (tabular.ts:107) already usedJSON.stringify([...]); this commit aligns the other two with it.frontend/next.config.ts(commit8d41184) - addsoutput: "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.shJWTaudclaim (commit9c7218a) - explicitaud:authenticatedon minted JWTs so PostgREST'sPGRST_JWT_AUDcheck 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 + addGOTRUE_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.exampleandfrontend/.env.local.examplestay intact)
Verification
A clean-clone smoke test runs through:
docker compose up -d --buildbrings every service to healthyhttp://localhost/serves the UI; signup creates a row inauth.usersandpublic.user_profiles/auth/v1/health,/rest/v1/user_profiles?select=id&limit=1,/backend/healthall 200 through Caddy- Chat with Gemini streams back end-to-end (frontend → Caddy → backend → Gemini)
- PDF upload lands in Garage at
documents/<userId>/<docId>/source.pdf, verified viaListObjectsV2 docker compose down && docker compose up -dbrings 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 --buildreaches all-healthy on a clean checkout -
http://localhost/signupcreates a user;http://localhost/account/modelsaccepts 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 -dreturns 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 bodyThe 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 bodyThe 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 bodyPostgrestFilterBuilder'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 bodyDrops 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 bodyAdds 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 bodyTwo 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 bodyPostgREST 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.