1d198acc97
The from-scratch dev login was broken: open the banner's http://localhost:3000, sign in as the seeded admin, and you landed on http://127.0.0.1:3000/error "Page not found". Root cause: the banner/APP_URL said localhost but kratos.yml hard-coded 127.0.0.1, and a host-scoped Kratos CSRF cookie can't cross localhost<->127.0.0.1, so the cross-host login POST lost it; Kratos redirected to its error sink, which the app had no route for (404). Make APP_URL the single source of truth for the public host: - Canonical-host redirect (app.ts): when APP_URL is set, an off-host GET/HEAD visitor is 308'd to it (path+query kept) before a flow starts, so the browser, the themed forms and the cross-origin Kratos POST share one cookie host. After /public/ so static/health checks stay host-agnostic; GET/HEAD only so a 308 never replays a cross-host POST. - Opt-in (no NODE_ENV / no magic default): unset => no redirect, so a prod deploy that forgets APP_URL can't bounce real users to a stale default. The dev stack sets it. - kratos.yml browser URLs default to localhost (match APP_URL's dev value) and derive from ${APP_URL} via compose.override.yml; SERVE_PUBLIC_BASE_URL keeps the dev Ory port. - Real /error page (views/error.ejs) replaces the catch-all 404 for genuine flow errors. Tests-first: config (opt-in/validated), app (308 on mismatch, no-redirect on match, static host-agnostic, POST untouched, /error page), updated kratos.test host pins. New devstack regression (e2e/devstack-login.spec + compose.e2e-devstack.yml) drives the plain docker-compose-up topology on the host network: login from localhost works and 127.0.0.1 is canonicalised; wired into scripts/ci.sh. typecheck + 360 units + full ci.sh (visual 10 · auth 1 · oauth 2 · full 7 · devstack 2) green.
182 lines
7.2 KiB
YAML
182 lines
7.2 KiB
YAML
# 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 CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
|
|
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
|
|
environment:
|
|
# Canonical public URL — set it to your domain to enable the canonical-host redirect (off when
|
|
# empty/unset, so a forgotten value never bounces real users). Your reverse proxy must preserve
|
|
# the public Host (or forward it) or a Host-rewriting proxy can loop. Kratos browser URLs and
|
|
# the banner derive from the same APP_URL. The dev override sets it to localhost.
|
|
APP_URL: ${APP_URL:-}
|
|
CACHE_TEMPLATES: "true"
|
|
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
|
|
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
|
REQUIRE_SECURE_SECRETS: "true"
|
|
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
|
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/
|
|
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
|
depends_on:
|
|
bootstrap:
|
|
condition: service_completed_successfully
|
|
kratos:
|
|
condition: service_healthy
|
|
keto:
|
|
condition: service_healthy
|
|
hydra:
|
|
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}
|
|
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
|
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
|
ADMIN_ROLES: ${ADMIN_ROLES:-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. web implements the
|
|
# login challenge at /oauth2/login (§6, consent next). 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:
|