# Plainpages A self-hostable foundation for server-rendered web apps — public or gated pages from a zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in. Every domain feature is a drop-in plugin folder; the app is stateless, no build step. **Source, docs & issues: ** ([GitHub mirror](https://github.com/larvit/plainpages)) ## Tags `X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. Pin the exact `X.Y.Z` you deploy. ## Quick start This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**, so there is nothing to clone. In an empty directory, save this as `compose.yml`: ```yaml services: web: image: larvit/plainpages:0.0.2 ports: - "3000:3000" environment: APP_URL: http://localhost:3000 depends_on: bootstrap: condition: service_completed_successfully kratos: condition: service_healthy keto: condition: service_healthy volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./plugins:/app/plugins restart: unless-stopped # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. bootstrap: image: larvit/plainpages:0.0.2 command: node src/auth/bootstrap.ts depends_on: kratos: condition: service_healthy keto: condition: service_healthy volumes: - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./plugins:/app/plugins:ro restart: "on-failure:5" postgres: image: postgres:18.4-alpine3.23 environment: POSTGRES_DB: ory POSTGRES_PASSWORD: ory POSTGRES_USER: ory volumes: - ./ory/postgres/init:/docker-entrypoint-initdb.d:ro - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ory -d ory"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped kratos-migrate: image: oryd/kratos:v26.2.0 command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes depends_on: postgres: condition: service_healthy environment: DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable volumes: - ./ory/kratos:/etc/config/kratos:ro restart: on-failure kratos: image: oryd/kratos:v26.2.0 command: serve -c /etc/config/kratos/kratos.yml --watch-courier ports: - "4433:4433" # the login form POSTs straight to Kratos from the browser depends_on: kratos-migrate: condition: service_completed_successfully environment: DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable volumes: - ./ory/kratos:/etc/config/kratos:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped keto-migrate: image: oryd/keto:v26.2.0 command: -c /etc/config/keto/keto.yml migrate up -y depends_on: postgres: condition: service_healthy environment: DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable volumes: - ./ory/keto:/etc/config/keto:ro restart: on-failure keto: image: oryd/keto:v26.2.0 command: serve -c /etc/config/keto/keto.yml depends_on: keto-migrate: condition: service_completed_successfully environment: DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable volumes: - ./ory/keto:/etc/config/keto:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped # Catches Kratos' recovery/verification emails — UI on http://localhost:8025 mailpit: image: axllent/mailpit:v1.30.1 ports: - "8025:8025" restart: unless-stopped volumes: pgdata: ``` Extract the Ory config the image ships, then start: ```bash docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf - mkdir -p plugins docker compose up -d ``` Open and sign in as `admin@plainpages.local` / `admin`. This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the OAuth2 provider — only needed when other apps log in *through* Plainpages). For production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT signing key), Hydra — see the repo README → Production & deployment. ## Configuration Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones: | Var | Default | What | | --- | --- | --- | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) | | `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it | | `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) | | `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod | | `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints | | `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs | | `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector | | `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` | | `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) | Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration. ## Your first plugin Everything domain-specific is a plugin folder — the compose above mounts `./plugins` into the app. Create `plugins/hello/plugin.ts`: ```ts import { definePlugin } from "#plugin-api"; export default definePlugin({ apiVersion: "1.0.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, ], }); ``` Restart (`docker compose restart web`) and visit . Views, forms, permissions, and the runnable reference plugin: repo README → Building plugins.