# 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" # Wait for the identity/permission services the app talks to (config.ts: kratos + keto) # and for the one-shot bootstrap to seed the admin + JWKS. Hydra is post-MVP (§6) and # absent from config.ts, so web doesn't gate on it. depends_on: bootstrap: condition: service_completed_successfully kratos: condition: service_healthy keto: condition: service_healthy # Read the session-JWT verify key from the same tokenizer JWKS Kratos signs with # (config.ts JWKS_URL default; §4 verifier). 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 + identity schema in ory/kratos/. # DSN is the per-service `kratos` DB (init.sql); supply 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). Permission model in ory/keto/namespaces.keto.ts (OPL). # DSN is the per-service `keto` DB (init.sql). The web app 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-command bootstrap (§3, the MVP bar): a one-shot that seeds first-boot state, then # exits — generate the JWKS if absent, create the demo admin (admin@plainpages.local / # admin) in Kratos, grant it the `admin` role in Keto. Idempotent, so it re-runs cleanly. # Runs once kratos+keto are healthy; web waits for it to complete. Tokenizer dir is # 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 (409-create + idempotent PUT), so transient Ory # blips recover — but a permanent error must give up, not loop forever and hang `web` # (which gates on service_completed_successfully). restart: "on-failure:5" # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # DSN is the per-service `hydra` DB (init.sql). Issuer + login/consent/logout run at # our app routes (ory/hydra/hydra.yml); the handlers that drive them are §6. Dev # permits the http issuer via --dev (compose.override.yml); prod supplies 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: