x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "5" services: # ─── Next.js Application ────────────────────────────────────────── app: build: context: . dockerfile: Dockerfile restart: unless-stopped logging: *default-logging depends_on: supabase-kong: condition: service_started environment: - TMDB_API_KEY=${TMDB_API_KEY} - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} - NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY} - SUPABASE_INTERNAL_URL=http://supabase-kong:8000 - SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY} - MASTER_ADMIN_USERNAME=${MASTER_ADMIN_USERNAME} - MASTER_ADMIN_TOTP_SECRET=${MASTER_ADMIN_TOTP_SECRET} - IRON_SESSION_SECRET=${IRON_SESSION_SECRET} - JWT_SECRET=${JWT_SECRET} - RECOVERY_CODE_PEPPER=${RECOVERY_CODE_PEPPER} networks: - internal # ─── Caddy Reverse Proxy ────────────────────────────────────────── caddy: image: caddy:2-alpine restart: unless-stopped logging: *default-logging ports: - "80:80" - "443:443" - "443:443/udp" environment: - DOMAIN=${DOMAIN:-localhost} - TLS_EMAIL=${TLS_EMAIL:-} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - app networks: - internal # ─── Supabase: Postgres ─────────────────────────────────────────── supabase-db: image: supabase/postgres:15.8.1.060 restart: unless-stopped logging: *default-logging healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-supabase_admin} -d ${POSTGRES_DB:-supabase}"] interval: 10s timeout: 5s retries: 5 environment: POSTGRES_USER: ${POSTGRES_USER:-supabase_admin} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-supabase} JWT_SECRET: ${JWT_SECRET} volumes: - supabase_db:/var/lib/postgresql/data networks: - internal # ─── Supabase: GoTrue (Auth) ────────────────────────────────────── supabase-auth: # pinned for supabase/auth#2013 workaround — see /home/user/.claude/plans/exactly-yes-precious-knuth.md image: supabase/gotrue:v2.170.0 restart: unless-stopped logging: *default-logging depends_on: supabase-db: condition: service_healthy environment: GOTRUE_API_HOST: "0.0.0.0" GOTRUE_API_PORT: "9999" API_EXTERNAL_URL: ${NEXT_PUBLIC_SUPABASE_URL} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-supabase_admin}:${POSTGRES_PASSWORD}@supabase-db:5432/${POSTGRES_DB:-supabase}?search_path=auth GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000} GOTRUE_URI_ALLOW_LIST: "" # Must be false for signInAnonymously() (used by /api/auth/bootstrap) # to work in GoTrue v2.170.0 — DISABLE_SIGNUP=true overrides the # ANONYMOUS_USERS_ENABLED flag in this version. Public email signup # is still neutralized in practice: no SMTP is configured and # MAILER_AUTOCONFIRM=false, so any account created via public /signup # remains unconfirmable. The recovery flow (admin.updateUserById in # /api/auth/recovery/generate) uses the service-role admin endpoint, # unaffected by this flag. GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_JWT_EXP: "3600" GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated # Anonymous auth enabled — core requirement. Newer GoTrue (v2.157+) # reads GOTRUE_ANONYMOUS_USERS_ENABLED; the older EXTERNAL_-prefixed # name is kept for backward compat / belt-and-braces. GOTRUE_ANONYMOUS_USERS_ENABLED: "true" GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true" # Non-anonymous auth surfaces are denied at Kong (see supabase/kong/kong.yml # request-termination routes for /auth/v1/magiclink, /recover, /otp, # /resend, /sso, /sso/saml). Public /signup remains reachable but is # neutralized: no SMTP is configured and MAILER_AUTOCONFIRM=false, so # any account created there is unconfirmable. EMAIL_ENABLED stays true # only because GoTrue v2.170.0 ties anonymous sign-in's user creation # path to the email provider being enabled. GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" GOTRUE_EXTERNAL_PHONE_ENABLED: "false" GOTRUE_MAILER_AUTOCONFIRM: "false" GOTRUE_SMS_AUTOCONFIRM: "false" networks: - internal # ─── Supabase: PostgREST ────────────────────────────────────────── supabase-rest: image: postgrest/postgrest:v12.2.8 restart: unless-stopped logging: *default-logging depends_on: supabase-db: condition: service_healthy environment: PGRST_DB_URI: postgres://${POSTGRES_USER:-supabase_admin}:${POSTGRES_PASSWORD}@supabase-db:5432/${POSTGRES_DB:-supabase} PGRST_DB_SCHEMAS: public PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" networks: - internal # ─── Supabase: Realtime schema bootstrap ────────────────────────── # Realtime's Ecto migrations target the `_realtime` schema and crash with # `no schema has been selected to create in` if it doesn't exist yet # (DB_AFTER_CONNECT_QUERY runs `SET search_path TO _realtime` before any # migration). The supabase/postgres image's bundled migrations create # `realtime` (WALRUS) but NOT `_realtime`. This one-shot creates it so # supabase-realtime can boot cleanly on a fresh volume. supabase-realtime-schema-init: image: postgres:15-alpine restart: "no" logging: *default-logging depends_on: supabase-db: condition: service_healthy environment: PGPASSWORD: ${POSTGRES_PASSWORD} command: > psql -h supabase-db -U ${POSTGRES_USER:-supabase_admin} -d ${POSTGRES_DB:-supabase} -v ON_ERROR_STOP=1 -c "CREATE SCHEMA IF NOT EXISTS _realtime;" -c "GRANT ALL ON SCHEMA _realtime TO ${POSTGRES_USER:-supabase_admin};" networks: - internal # ─── Supabase: Realtime ─────────────────────────────────────────── supabase-realtime: image: supabase/realtime:v2.34.47 restart: unless-stopped logging: *default-logging depends_on: supabase-db: condition: service_healthy supabase-realtime-schema-init: condition: service_completed_successfully environment: PORT: "4000" DB_HOST: supabase-db DB_PORT: "5432" DB_USER: ${POSTGRES_USER:-supabase_admin} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB:-supabase} DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime" DB_ENC_KEY: supabaserealtime API_JWT_SECRET: ${JWT_SECRET} SECRET_KEY_BASE: ${REALTIME_SECRET_KEY_BASE:-please-generate-a-64-char-secret-key-base-for-realtime-service!!} ERL_AFLAGS: "-proto_dist inet_tcp" DNS_NODES: "''" RLIMIT_NOFILE: "10000" APP_NAME: realtime SEED_SELF_HOST: "true" networks: - internal # ─── Supabase: Realtime tenant seed ─────────────────────────────── # Realtime is multi-tenant. Reaching it via Kong rewrites the upstream # Host to `supabase-realtime:4000`, which it maps to external_id # `supabase-realtime`. The built-in SEED_SELF_HOST seeds `realtime-dev` # (wrong slug) and never creates `supabase-realtime`. This one-shot # POSTs /api/tenants to create the row. Idempotent (GET first; skip if # present). The tenant row lives in the `_realtime.tenants` table inside # the postgres data volume, so this only does real work on a cold start. supabase-realtime-tenant-seed: image: alpine:3.20 restart: "no" logging: *default-logging depends_on: supabase-realtime: condition: service_started environment: JWT_SECRET: ${JWT_SECRET} POSTGRES_USER: ${POSTGRES_USER:-supabase_admin} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-supabase} volumes: - ./supabase/init/seed-realtime-tenant.sh:/seed-realtime-tenant.sh:ro entrypoint: ["/bin/sh", "-c"] command: > "apk add --no-cache curl postgresql-client openssl >/dev/null && /seed-realtime-tenant.sh" networks: - internal # ─── Supabase: Kong (API Gateway) ───────────────────────────────── supabase-kong: image: kong:2.8.1 restart: unless-stopped logging: *default-logging depends_on: - supabase-auth - supabase-rest - supabase-realtime environment: KONG_DATABASE: "off" # Kong 2.x has no native env-var interpolation in declarative config # (env vault is 3.x+). The entrypoint below seds ANON_KEY / # SERVICE_ROLE_KEY into a writable copy before kong starts. KONG_DECLARATIVE_CONFIG: /tmp/kong.yml KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,request-termination KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k ANON_KEY: ${ANON_KEY} SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} volumes: - ./supabase/kong/kong.yml:/etc/kong-template/kong.yml:ro entrypoint: ["/bin/sh", "-c"] command: - | sed -e 's|$${ANON_KEY}|'"$$ANON_KEY"'|g' \ -e 's|$${SERVICE_ROLE_KEY}|'"$$SERVICE_ROLE_KEY"'|g' \ /etc/kong-template/kong.yml > /tmp/kong.yml \ && exec /docker-entrypoint.sh kong docker-start networks: - internal # ─── Supabase: Studio (dev only) ────────────────────────────────── supabase-studio: image: supabase/studio:20250317-b9tried restart: unless-stopped logging: *default-logging ports: - "127.0.0.1:3001:3000" depends_on: - supabase-kong environment: STUDIO_PG_META_URL: http://supabase-meta:8080 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} SUPABASE_URL: http://supabase-kong:8000 SUPABASE_REST_URL: http://supabase-kong:8000/rest/v1/ SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} DEFAULT_ORGANIZATION_NAME: MovieDice DEFAULT_PROJECT_NAME: MovieDice NEXT_PUBLIC_ENABLE_LOGS: "true" NEXT_ANALYTICS_BACKEND_PROVIDER: postgres networks: - internal # ─── Supabase: pg_meta (required by Studio) ─────────────────────── supabase-meta: image: supabase/postgres-meta:v0.84.2 restart: unless-stopped logging: *default-logging depends_on: supabase-db: condition: service_healthy environment: PG_META_PORT: "8080" PG_META_DB_HOST: supabase-db PG_META_DB_PORT: "5432" PG_META_DB_NAME: ${POSTGRES_DB:-supabase} PG_META_DB_USER: ${POSTGRES_USER:-supabase_admin} PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} networks: - internal # ─── Cron Service ───────────────────────────────────────────────── cron: build: context: ./cron dockerfile: Dockerfile restart: unless-stopped logging: *default-logging depends_on: supabase-kong: condition: service_started environment: - SUPABASE_URL=http://supabase-kong:8000 - SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY} - TMDB_API_KEY=${TMDB_API_KEY} networks: - internal # ─── Database Backup ────────────────────────────────────────────── backup: image: postgres:15-alpine restart: "no" logging: *default-logging depends_on: supabase-db: condition: service_healthy environment: POSTGRES_USER: ${POSTGRES_USER:-supabase_admin} POSTGRES_DB: ${POSTGRES_DB:-supabase} PGPASSWORD: ${POSTGRES_PASSWORD} volumes: - ./backup/backup.sh:/backup.sh:ro - supabase_backups:/backups entrypoint: ["/bin/sh", "/backup.sh"] networks: - internal profiles: - backup volumes: supabase_db: supabase_backups: caddy_data: caddy_config: networks: internal: driver: bridge