Lef-F adds full docker-compose self-host stack: Postgres, GoTrue, PostgREST, Garage, Caddy

Lef-F replaced Supabase Cloud and Cloudflare R2 with a single-host docker-compose stack. Run `docker compose up` and you have the full service mesh with no external managed services.

infrastructurecompliance

The stack lands via PR #1 (325510e) on a lef/docker-compose branch. Services: Postgres 16 as the database, GoTrue v2.166.0 for auth, PostgREST v12.2.3 for the REST layer, Garage v2 as S3-compatible object storage, dedicated Dockerfiles for backend (Node 22 + LibreOffice) and frontend (Next.js 16 standalone), and Caddy 2 as the single ingress routing everything onto one configurable port. Two one-shot init containers handle first-boot bootstrap idempotently and then exit.

Setup is: cp .env.example .env, run ./scripts/generate-secrets.sh (POSIX shell + openssl, no Node required), set your LLM key, docker compose up -d --build. The secrets script mints JWTs from scratch and writes chmod 600 files; --force regenerates everything.

A few specific issues surfaced during first-boot testing that are worth reading before adopting. The init-garage container was changed from the full Garage image to alpine:3.20 with the /garage binary copied in via a multi-stage Dockerfile - the original image's RPC CLI didn't work cleanly in the bootstrap context, so the script was rewritten to use the v2 HTTP admin API instead. For backend auth, SUPABASE_URL was changed to point at Caddy rather than directly at GoTrue (http://caddy:${MIKE_PORT}), with header_up Host {upstream_hostport} added to each Caddy reverse_proxy block - this is required because supabase-js constructs auth paths relative to the base URL in ways that break if the backend talks directly to GoTrue. For S3, Garage needs a mike.garage Docker network alias and s3_region = "auto" / root_domain = ".garage" in garage.toml to satisfy the AWS SDK's virtual-hosted-style URL construction.

The frontend image size was cut from 1.98 GB to ~465 MB by switching to Next.js standalone output (output: "standalone" in frontend/next.config.ts, runtime stage copies .next/standalone instead of node_modules). That next.config.ts change is the only edit that touches a non-self-host file.

Known constraints: changing MIKE_PORT requires docker compose build mike-frontend because Next.js bakes URLs at build time. The Garage network alias is hardcoded to bucket name mike; renaming R2_BUCKET_NAME requires a compose edit. TLS, real SMTP, multi-node Garage, and backups are all explicitly out of scope.

So what Worth a look if you want to offer users a self-host option with no Supabase or R2 dependency. The networking fixes discovered during testing (auth routing through Caddy, Garage virtual-hosted alias) are non-obvious and would likely trip up anyone attempting the same migration independently. The backend auth routing change departs from upstream's wiring assumptions, so verify before making any future changes to auth flow.

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