# MikeOSS — full local stack, one `docker compose up`. No external accounts. # Supabase is embedded (db + auth + rest + gateway); storage is RustFS. # The only thing you must supply is one model API key in backend/.env. # # JWT secret + anon/service_role keys below are the well-known Supabase local # demo values — fine for localhost, NOT for anything exposed. The keys are JWTs # signed with the secret; don't change one without regenerating the others. services: # --- Supabase: Postgres (ships base roles, the `auth` schema, auth.uid, etc.) db: image: supabase/postgres:17.6.1.136 ports: # ponytail: infra ports bind loopback only — reachable from the host for # debugging, never from the internet. Compose networking is unaffected, so # the other services still reach these by service name. - "127.0.0.1:${DB_PORT:-54322}:5432" # psql from host if needed environment: POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long JWT_EXP: 3600 volumes: - db_data:/var/lib/postgresql/data - ./docker/db-init/roles.sql:/docker-entrypoint-initdb.d/zz-roles.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped # --- Supabase: Auth (GoTrue). Auto-runs its migrations to build auth.users. auth: image: supabase/gotrue:v2.189.0 environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: "9999" # Must be the browser-reachable gateway URL — confirmation links are built # from it, so a localhost value makes signup uncompletable off-machine. API_EXTERNAL_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:54321} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:postgres@db:5432/postgres GOTRUE_SITE_URL: ${FRONTEND_URL:-http://localhost:3000} GOTRUE_URI_ALLOW_LIST: "*" GOTRUE_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long GOTRUE_JWT_EXP: "3600" GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_PASSWORD_MIN_LENGTH: "10" # Open registration by default (localhost). On a public host, create your # account then set GOTRUE_DISABLE_SIGNUP=true to pull the ladder up. GOTRUE_DISABLE_SIGNUP: ${GOTRUE_DISABLE_SIGNUP:-false} GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_EXTERNAL_PHONE_ENABLED: "false" GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOTRUE_EXTERNAL_GOOGLE_ENABLED:-true} GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID:-} GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOTRUE_EXTERNAL_GOOGLE_SECRET:-} GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${SUPABASE_PUBLIC_URL:-http://localhost:54321}/auth/v1/callback # Enterprise SAML is opt-in. Set these in the Compose environment/root # .env (not backend/.env); see docs/deployment.md for provider setup. # GOTRUE_SAML_ENABLED: "true" # GOTRUE_SAML_PRIVATE_KEY: ${GOTRUE_SAML_PRIVATE_KEY:-} # GOTRUE_SAML_EXTERNAL_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:54321}/auth/v1 # Off by default: shares and organization invitations are addressed by # email, so an unconfirmed signup must not be able to claim an address # it does not own. Confirmation mail is caught by Mailpit locally. Set # this to true only for a throwaway local stack. GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-false} GOTRUE_SMTP_HOST: mailpit GOTRUE_SMTP_PORT: "1025" # No SMTP_USER/PASS: Mailpit needs no auth, and GoTrue refuses to send # credentials over a plaintext (non-TLS) connection ("unencrypted connection"). GOTRUE_SMTP_ADMIN_EMAIL: admin@mike.local GOTRUE_SMTP_SENDER_NAME: Mike # Confirmation/recovery links resolve to the gateway, which strips /auth/v1. GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify depends_on: db: condition: service_healthy mailpit: condition: service_started restart: unless-stopped # --- Supabase: data API (PostgREST). Backend queries tables through this. rest: image: postgrest/postgrest:v14.12 depends_on: db: condition: service_healthy environment: PGRST_DB_URI: postgres://authenticator:postgres@db:5432/postgres PGRST_DB_SCHEMAS: public PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long restart: unless-stopped # --- Supabase: gateway. The backend reaches Auth/PostgREST through here. gateway: image: nginx:alpine ports: # Public only so confirmation links generated by the local Auth service work. - "${GATEWAY_PORT:-54321}:8000" volumes: - ./supabase/gateway.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - auth - rest healthcheck: test: [ "CMD-SHELL", "wget -q --spider http://127.0.0.1:8000/rest/v1/ || exit 1", ] interval: 5s timeout: 5s retries: 20 restart: unless-stopped # --- One-shot: load schema.sql on a new volume, then apply any pending # migrations with backend/scripts/migrate.sh, which records each one in # public.schema_migrations so the next start skips it; grant service_role. # Runs from the backend image (which carries schema.sql, migrations/ and the # scripts, plus psql) rather than bind-mounting them from the checkout, so a # pulled image can never be paired with another version's migrations. db-init: # Every backend consumer (db-init, workflow-sync, backend, worker) runs the # same image, so code, schema and migrations always come from one release. # The default tag is the version in /VERSION; `docker compose up --build` # still builds it from source (backend/src/__tests__/releaseVersion.test.ts # keeps the two in step). build: &backend-build context: . dockerfile: backend/Dockerfile args: # Release identity for Sentry (docs/observability.md): build args, not # runtime variables, because the Dockerfile bakes them into the image. MIKE_VERSION: ${MIKE_VERSION:-} GIT_SHA: ${GIT_SHA:-} image: &backend-image ${MIKE_IMAGE_REPO:-ghcr.io/open-legal-products}/mike-backend:${MIKE_IMAGE_TAG:-2.1.0} depends_on: db: condition: service_healthy auth: condition: service_started environment: DB: postgres://postgres:postgres@db:5432/postgres DATABASE_URL: postgres://postgres:postgres@db:5432/postgres MIGRATIONS_DIR: /app/migrations # adopt-ledger.sh (pre-ledger volumes) runs migrate.sh from here too. MIGRATE_SH: /app/scripts/migrate.sh entrypoint: ["bash", "-c"] command: - | until psql "$$DB" -tAc "select to_regclass('auth.users')" | grep -q auth; do echo "waiting for auth schema..."; sleep 2; done if ! psql "$$DB" -tAc "select to_regclass('public.user_profiles')" | grep -q user_profiles; then # New volume: schema.sql is the complete schema, and it records every # migration it already contains in the ledger. psql "$$DB" -v ON_ERROR_STOP=1 -f /app/schema.sql || exit 1; elif ! psql "$$DB" -tAc "select to_regclass('public.schema_migrations')" | grep -q schema_migrations; then # Volume from before the ledger: replay one last time the way # db-init used to on every start, then record it. bash /app/docker/db-init/adopt-ledger.sh || exit 1; fi bash /app/scripts/migrate.sh up || exit 1; psql "$$DB" -c "grant usage on schema public to service_role; grant all privileges on all tables in schema public to service_role; grant all privileges on all sequences in schema public to service_role; notify pgrst, 'reload schema';" restart: "no" # --- Local SMTP catcher. Auth emails land in its web inbox (no real sending). mailpit: image: axllent/mailpit:latest ports: # Loopback only: the inbox exposes auth + password-reset links. - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" # web inbox - "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025" # SMTP environment: MP_SMTP_AUTH_ACCEPT_ANY: "1" MP_SMTP_AUTH_ALLOW_INSECURE: "1" restart: unless-stopped # --- S3-compatible object storage (RustFS, MinIO alternative). storage: image: rustfs/rustfs:latest ports: # Loopback only: creds below are the default admin pair. - "127.0.0.1:${STORAGE_PORT:-9000}:9000" - "127.0.0.1:${STORAGE_CONSOLE_PORT:-9001}:9001" environment: RUSTFS_VOLUMES: /data RUSTFS_ADDRESS: 0.0.0.0:9000 RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001 RUSTFS_CONSOLE_ENABLE: "true" RUSTFS_ACCESS_KEY: rustfsadmin RUSTFS_SECRET_KEY: rustfsadmin volumes: - storage_data:/data restart: unless-stopped # Writes the bucket CORS policy createbucket installs: browsers PUT uploads # straight to storage, so the bucket must allow every origin the backend # trusts (FRONTEND_URL, WORD_ADDIN_URL, ALLOWED_ORIGINS). It runs the # backend image's own origin parser (docker/storage-cors.mjs) with the # backend's env_file and the same overrides as the backend service below, # so the two allowlists cannot disagree. Keep these three blocks in step # with the backend service (backend/src/__tests__/storageCors.test.ts). storage-cors: build: *backend-build image: *backend-image env_file: - path: ./backend/.env required: false - path: .env required: false environment: - NODE_ENV=${NODE_ENV:-development} - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} - MIKE_ORIGINS_MODULE=/app/dist/lib/origins.js volumes: - ./docker/storage-cors.mjs:/storage-cors.mjs:ro - storage_cors:/cors entrypoint: /bin/sh command: ["-c", "node /storage-cors.mjs > /cors/storage-cors.json"] # It reads the backend's env_file (provider keys and signing secrets # included) only to print a JSON policy, so it gets no network at all. network_mode: none restart: "no" createbucket: image: amazon/aws-cli depends_on: storage: condition: service_started storage-cors: condition: service_completed_successfully environment: AWS_ACCESS_KEY_ID: rustfsadmin AWS_SECRET_ACCESS_KEY: rustfsadmin AWS_DEFAULT_REGION: us-east-1 volumes: - storage_cors:/cors:ro entrypoint: /bin/sh command: > -c "until aws --endpoint-url http://storage:9000 s3 ls; do echo 'waiting for storage'; sleep 2; done; aws --endpoint-url http://storage:9000 s3 mb s3://mike || true; aws --endpoint-url http://storage:9000 s3api put-bucket-cors --bucket mike --cors-configuration file:///cors/storage-cors.json" restart: "no" # --- Redis: backs the BullMQ job queues (conversion, extraction, fast # delivery for background jobs). NEW INSTALLS get it out of the box, which # is why the backend below ships with the ASYNC_* flags on. Existing # deployments that upgrade in place are untouched: the flags only default # on here in compose, never in code, and without Redis configured the # backend falls back to its Postgres-backed queue automatically. # AOF persistence is on so queued jobs survive a Redis restart. redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] ports: # Loopback only — debugging convenience, never internet-reachable. - "127.0.0.1:${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 20 restart: unless-stopped # --- One-shot: download, validate, and transactionally import mike-workflows. workflow-sync: build: *backend-build image: *backend-image env_file: - path: ./backend/.env required: false - path: .env required: false environment: - SUPABASE_URL=http://gateway:8000 - SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU} - R2_ENDPOINT_URL=http://storage:9000 - R2_ACCESS_KEY_ID=rustfsadmin - R2_SECRET_ACCESS_KEY=rustfsadmin - R2_BUCKET_NAME=mike command: ["npm", "run", "sync:workflows"] depends_on: db-init: condition: service_completed_successfully createbucket: condition: service_completed_successfully gateway: condition: service_healthy restart: "no" backend: build: *backend-build image: *backend-image ports: - "${BACKEND_PORT:-3001}:3001" # ponytail: both optional — local dev keeps secrets in backend/.env, hosted # deploys (Dokploy et al) write them to the compose-root .env. Whichever # exists wins; root .env is last so it takes precedence. Needs Compose 2.24+. # # Secrets must NOT be listed in `environment:` below — that section overrides # env_file, so a bare name unset on the host blanks out the file's value. env_file: - path: ./backend/.env required: false - path: .env required: false environment: # Topology + local Supabase wiring (override .env placeholders). - PORT=3001 - NODE_ENV=${NODE_ENV:-development} - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} - API_PUBLIC_URL=${API_PUBLIC_URL:-http://localhost:3000/api} - SUPABASE_URL=http://gateway:8000 - SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0} - SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU} - R2_ENDPOINT_URL=http://storage:9000 # The storage URL BROWSERS reach for direct uploads/downloads. The # default only works when the app is opened on this machine; for any # other host set it to a public (https under an https front end) URL # that reaches the storage service. See docs/deployment.md. - R2_PUBLIC_ENDPOINT_URL=${R2_PUBLIC_ENDPOINT_URL:-http://localhost:${STORAGE_PORT:-9000}} - R2_ACCESS_KEY_ID=rustfsadmin - R2_SECRET_ACCESS_KEY=rustfsadmin - R2_BUCKET_NAME=mike # Local Ollama running on the host. Override OLLAMA_MODEL to change the tag. - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434/v1} # Durable job queues: fresh compose installs run BullMQ-on-Redis for # everything (conversion + extraction async, instant delivery for # background jobs). Deployments without Redis fall back to the # Postgres queue; these flags are compose-level so in-place upgrades # of non-compose installs see no behavior change. - REDIS_URL=${REDIS_URL:-redis://redis:6379} - ASYNC_DOCUMENT_CONVERSION=${ASYNC_DOCUMENT_CONVERSION:-true} - ASYNC_TABULAR_EXTRACTION=${ASYNC_TABULAR_EXTRACTION:-true} # Background workers run in a worker_thread inside this container by # default (off the HTTP event loop). To move them to their own # container/machine, set WORKERS_MODE=none here and enable the # `worker` service below. - WORKERS_MODE=${WORKERS_MODE:-thread} extra_hosts: - "host.docker.internal:host-gateway" depends_on: workflow-sync: condition: service_completed_successfully auth: condition: service_started rest: condition: service_started redis: condition: service_healthy restart: unless-stopped # --- Optional: dedicated worker process ("async computer" mode). # Same image as the backend, different command: it runs ONLY the queue # workers and maintenance sweeps against the same Postgres/Redis. To use: # uncomment this service AND set WORKERS_MODE=none on the backend above so # background work runs exactly once. Scale-out is safe by construction — # BullMQ partitions per connection and the DB queue claims with # FOR UPDATE SKIP LOCKED, so replicas divide jobs, never duplicate them. # worker: # build: *backend-build # image: *backend-image # command: ["node", "dist/worker.js"] # env_file: # - path: ./backend/.env # required: false # - path: .env # required: false # environment: # - SUPABASE_URL=http://gateway:8000 # - SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU} # - R2_ENDPOINT_URL=http://storage:9000 # - R2_ACCESS_KEY_ID=rustfsadmin # - R2_SECRET_ACCESS_KEY=rustfsadmin # - R2_BUCKET_NAME=mike # - REDIS_URL=${REDIS_URL:-redis://redis:6379} # - ASYNC_DOCUMENT_CONVERSION=${ASYNC_DOCUMENT_CONVERSION:-true} # - ASYNC_TABULAR_EXTRACTION=${ASYNC_TABULAR_EXTRACTION:-true} # depends_on: # db-init: # condition: service_completed_successfully # redis: # condition: service_healthy # restart: unless-stopped frontend: build: # Repo-root context: the frontend's shared types import type-only # definitions from backend/src (see frontend/Dockerfile). context: . dockerfile: frontend/Dockerfile args: # Release identity (docs/observability.md), as for the backend image. MIKE_VERSION: ${MIKE_VERSION:-} # The browser-side Sentry DSN is inlined by `next build`, so it is a # build argument, not a runtime variable (docs/observability.md). NEXT_PUBLIC_SENTRY_DSN: ${FRONTEND_SENTRY_DSN:-} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-} NEXT_PUBLIC_SENTRY_RELEASE: ${SENTRY_RELEASE:-} # Opt-out and install kind reach the browser bundle at build time. NEXT_PUBLIC_SENTRY_DISABLED: ${SENTRY_DISABLED:-} NEXT_PUBLIC_SENTRY_INSTALL: ${SENTRY_INSTALL:-} GIT_SHA: ${GIT_SHA:-} # Sentry source-map upload (docs/observability.md); the token is the # build secret below. SENTRY_ORG: ${SENTRY_ORG:-} SENTRY_PROJECT: ${SENTRY_PROJECT:-} SENTRY_UPLOAD_ENABLED: ${SENTRY_UPLOAD_ENABLED:-0} secrets: - SENTRY_AUTH_TOKEN # Same default tag as the backend image; pull both or build both. image: ${MIKE_IMAGE_REPO:-ghcr.io/open-legal-products}/mike-frontend:${MIKE_IMAGE_TAG:-2.1.0} environment: - API_BASE_URL=${API_BASE_URL:-http://backend:3001} # Next server side (the /api gateway); same project as the browser. - SENTRY_DISABLED=${SENTRY_DISABLED:-} - SENTRY_INSTALL=${SENTRY_INSTALL:-} - SENTRY_DSN=${FRONTEND_SENTRY_DSN:-} - SENTRY_MAX_REPEAT_EVENTS_PER_DAY=${SENTRY_MAX_REPEAT_EVENTS_PER_DAY:-} - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-} - SENTRY_RELEASE=${SENTRY_RELEASE:-} ports: # Dokploy's own dashboard occupies 3000 — set FRONTEND_PORT there. - "${FRONTEND_PORT:-3000}:3000" depends_on: - backend restart: unless-stopped volumes: db_data: storage_data: storage_cors: redis_data: secrets: SENTRY_AUTH_TOKEN: # Must be exported/injected into the Compose process, not only in .env. environment: SENTRY_AUTH_TOKEN