# Base / production config. Run alone with: docker compose -f compose.yml up # Plain `docker compose up` also merges compose.override.yml for development. services: web: build: . ports: - "3000:3000" # Explicit behaviour toggles (the app is environment-agnostic — see AGENTS.md). # Supply COOKIE_SECRET / CSRF_SECRET via env; REQUIRE_SECURE_SECRETS refuses dev throwaways. environment: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "true" SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure # Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap # (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it. depends_on: bootstrap: condition: service_completed_successfully kratos: condition: service_healthy keto: condition: service_healthy # §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # Read-only — bootstrap is the only writer. volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro restart: unless-stopped # Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here. # init/init.sql creates one database per service. Dev defaults below; supply # POSTGRES_USER/PASSWORD via env in production. postgres: image: postgres:18.4-alpine3.23 environment: POSTGRES_USER: ${POSTGRES_USER:-ory} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory} POSTGRES_DB: ory volumes: - ./ory/postgres/init:/docker-entrypoint-initdb.d:ro - pgdata:/var/lib/postgresql # PG18+: mount the parent, not /data (version-subdir layout) healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ory} -d ory"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped # Ory Kratos — identity & self-service auth. Config + schema in ory/kratos/; DSN is # its own `kratos` DB (init.sql), POSTGRES_* via env in prod. kratos-migrate: image: oryd/kratos:v26.2.0 depends_on: postgres: condition: service_healthy environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/kratos?sslmode=disable volumes: - ./ory/kratos:/etc/config/kratos:ro command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes restart: on-failure kratos: image: oryd/kratos:v26.2.0 depends_on: kratos-migrate: condition: service_completed_successfully environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/kratos?sslmode=disable volumes: - ./ory/kratos:/etc/config/kratos:ro command: serve -c /etc/config/kratos/kratos.yml --watch-courier healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped # Ory Keto — authorization (ReBAC); OPL model in ory/keto/namespaces.keto.ts. DSN is # its own `keto` DB (init.sql). web calls its read/write APIs (config.ts). keto-migrate: image: oryd/keto:v26.2.0 depends_on: postgres: condition: service_healthy environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable volumes: - ./ory/keto:/etc/config/keto:ro command: -c /etc/config/keto/keto.yml migrate up -y restart: on-failure keto: image: oryd/keto:v26.2.0 depends_on: keto-migrate: condition: service_completed_successfully environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable volumes: - ./ory/keto:/etc/config/keto:ro command: serve -c /etc/config/keto/keto.yml healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped # One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs # cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted # read-write (the only writer) so the absent-JWKS safety net can land the key. bootstrap: build: . depends_on: kratos: condition: service_healthy keto: condition: service_healthy environment: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json KETO_WRITE_URL: http://keto:4467 KRATOS_ADMIN_URL: http://kratos:4434 volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer command: node src/bootstrap.ts # Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent # error must give up, not loop forever and hang `web` (gates on completion). restart: "on-failure:5" # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml, handlers are §6. # Dev permits the http issuer via --dev (compose.override.yml); prod sets an https # issuer via env (URLS_SELF_ISSUER). hydra-migrate: image: oryd/hydra:v26.2.0 depends_on: postgres: condition: service_healthy environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable volumes: - ./ory/hydra:/etc/config/hydra:ro command: -c /etc/config/hydra/hydra.yml migrate sql -e --yes restart: on-failure hydra: image: oryd/hydra:v26.2.0 depends_on: hydra-migrate: condition: service_completed_successfully environment: DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable volumes: - ./ory/hydra:/etc/config/hydra:ro command: serve all -c /etc/config/hydra/hydra.yml healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4444/health/ready"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped volumes: pgdata: