https://github.com/korjavin/ory-compose
Lightweight self-hosted Ory stack — Kratos (identity + social sign-in) + Hydra (OAuth2/OIDC for your apps) + Login UI, on SQLite. Git-ops deploy via Portainer + GHCR-vendored images.
https://github.com/korjavin/ory-compose
docker-compose hydra identity-management kratos oauth2 oidc ory portainer self-hosted sqlite
Last synced: 1 day ago
JSON representation
Lightweight self-hosted Ory stack — Kratos (identity + social sign-in) + Hydra (OAuth2/OIDC for your apps) + Login UI, on SQLite. Git-ops deploy via Portainer + GHCR-vendored images.
- Host: GitHub
- URL: https://github.com/korjavin/ory-compose
- Owner: korjavin
- License: mit
- Created: 2026-05-16T00:51:40.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-06-29T05:39:48.000Z (6 days ago)
- Last Synced: 2026-06-29T07:17:47.895Z (6 days ago)
- Topics: docker-compose, hydra, identity-management, kratos, oauth2, oidc, ory, portainer, self-hosted, sqlite
- Language: Go
- Size: 93.8 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Ory Stack (Kratos + Hydra + Login UI) for Portainer
A git-ops Docker Compose deployment that gives you:
- **Ory Kratos** — identity management. Sign-in surface is locked to **OIDC providers** (Google, GitHub, GitLab, Pocket-ID) and **passkeys** (WebAuthn passwordless). Password and email-magic-code login are off by default. TOTP and WebAuthn-2FA can be layered on top.
- **Ory Hydra** — OAuth2 / OIDC provider you point your other apps at (Outline, Forgejo, etc.). One Hydra client per app.
- **kratos-selfservice-ui-node** — reference Login / Registration / Settings / Recovery UI.
- **Custom consent service** (`consent/`) — replaces the Login UI's consent handler. Enforces per-client `required_groups` and copies `groups` into ID + access tokens, so each app's access is gated centrally.
- **Invite CLI** (`invite/`) — pre-creates a Kratos identity with the right group memberships and prints a 1h recovery link. Send the link, recipient picks whichever auth method(s) they want and links them all to the same identity.
Both Kratos and Hydra run on **SQLite** with data persisted in named Docker volumes — lightweight, no Postgres dependency.
## Architecture
```
Browser
│
▼
┌──────────────────────────────────────────────────┐
│ auth.example.com (one origin) │
│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │
│ │ Login UI │ │ Kratos │ │Consent │ │
│ │ (catch-all)│ │ (public, │ │service │ │
│ │ │ │ path-routed│ │/consent│ │
│ │ /login etc. │ │ /self- │ │ │ │
│ │ │ │ service/*) │ │ │ │
│ └──────────────┘ └──────┬───────┘ └────────┘ │
└─────────────────────────────│────────────────────┘
│ identity
▼
┌──────────────────┐
│ Hydra (public) │
│ hydra.example.com│
└────────┬─────────┘
│ OIDC discovery + tokens
▼
Your apps (Outline, Forgejo, …) — configure them
with hydra.example.com/.well-known/openid-configuration
```
Trust boundaries:
| Endpoint | Network | Exposure |
|----------------------------|-------------------|--------------------------------|
| Login UI (`:3000`) | traefik + internal| `https://${LOGIN_UI_HOST}` (catch-all paths) |
| Kratos public (`:4433`) | traefik + internal| `https://${LOGIN_UI_HOST}/self-service/…`, `/.well-known/…`, `/sessions/…`, `/schemas/…`, `/health/…` (path-routed, priority 90) |
| Consent (`:3001`) | traefik + internal| `https://${LOGIN_UI_HOST}/consent` (priority 100) |
| Kratos admin (`:4434`) | internal only | never on the public internet |
| Hydra public (`:4444`) | traefik + internal| `https://${HYDRA_PUBLIC_HOST}` |
| Hydra admin (`:4445`) | internal only | never on the public internet |
Login UI, Kratos public, and Consent all live on the **same origin** (`LOGIN_UI_HOST`) so CSRF and session cookies just work. Kratos's per-flow CSRF cookies don't honor `serve.public.cookies.domain` in v1.3.x, so a separate-subdomain layout produces an infinite redirect loop on every flow — same-origin sidesteps the bug entirely.
Admin APIs are reachable from other containers on the `ory_internal` Docker network. To talk to them from your laptop, use `docker exec ory-hydra hydra ...` or open a temporary SSH tunnel.
## Files
```
.
├── docker-compose.yml
├── .env.example
├── config/ # built into ory-kratos-config image
│ ├── Dockerfile # alpine + gettext, baked config files
│ ├── render.sh # entrypoint: envsubst + copy
│ └── kratos/
│ ├── kratos.yml.tmpl # rendered at startup with envsubst
│ ├── identity.schema.json # user shape (incl. groups[])
│ ├── oidc.google.jsonnet # Google → Kratos identity mapper
│ ├── oidc.pocket-id.jsonnet
│ ├── oidc.github.jsonnet
│ └── oidc.gitlab.jsonnet
├── consent/ # our Go consent service
│ ├── main.go
│ ├── go.mod
│ └── Dockerfile # → ghcr.io//ory-consent:latest
├── invite/ # our Go invite CLI
│ ├── main.go
│ ├── go.mod
│ └── Dockerfile # → ghcr.io//ory-invite:latest
└── .github/
├── scripts/
│ └── build-deploy-branch.sh # shared: rebuild deploy from master + tag pins
└── workflows/
├── deploy.yml # master push (non-image) → deploy branch
├── vendor-images.yml # weekly: pull oryd/* → push vendored to GHCR
└── build-services.yml # build & push consent + invite + kratos-config
```
## How deploys are pinned
`master` always references images as `:latest` (e.g. `ghcr.io/korjavin/ory-consent:latest`). The `deploy` branch — what Portainer actually pulls — is auto-generated with each image pinned to a concrete tag.
| Image | Pinned to |
|---|---|
| `ory-consent`, `ory-invite`, `ory-kratos-config` | the **master commit SHA** that last built it (built by `build-services.yml`) |
| `kratos-vendor`, `hydra-vendor`, `kratos-selfservice-ui-node-vendor` | `d-` (built by `vendor-images.yml`) |
Each deploy-branch commit also carries `image-tags.env`, recording the exact tags for that revision. To inspect what's currently deployed:
```bash
git show origin/deploy:image-tags.env
```
To roll back one image (e.g. revert ory-consent to its previous SHA):
```bash
# Find the SHA you want to roll back to
git log --oneline master -- consent/
# Force the deploy branch to that pinned tag
ORY_CONSENT_TAG= bash .github/scripts/build-deploy-branch.sh
```
Or just `git revert` the offending master commit; the next `Deploy Ory Stack` run repins automatically.
The pinning model means Portainer always sees a tag it hasn't pulled before → it pulls every redeploy → no more "Portainer cached `:latest`" surprises.
## How environment variables are wired
You said you don't want a `.env` file on the server — Portainer holds the values. The compose file references env names with sensible defaults; only **hostnames** and **secrets** truly need to be set in Portainer's stack-env panel.
Kratos itself doesn't natively read `${VAR}` from its YAML config. We work around that with a tiny init container `kratos-config` built from `./config/`. The image bakes in `gettext` (for `envsubst`), the `kratos.yml.tmpl` template, all OIDC mapper jsonnets, and the identity schema. On every restart its entrypoint renders the template against the current Portainer env vars and drops the result into a shared volume that Kratos mounts read-only at `/etc/config/kratos`.
Edit-and-deploy loop:
1. Edit `config/kratos/kratos.yml.tmpl` (or any other file in `config/`) on master.
2. The `Build Custom Services` workflow rebuilds `ghcr.io//ory-kratos-config:latest` and force-pushes the `deploy` branch.
3. Portainer pulls the new image on redeploy; the next `kratos-config` run renders the updated template.
## Required secrets
| Variable | Purpose | Generator |
|--------------------------------|----------------------------------------|----------------------------------------|
| `KRATOS_COOKIE_SECRET` | signs Kratos session cookies | `openssl rand -hex 32` |
| `KRATOS_CIPHER_SECRET` | encrypts secrets at rest in Kratos | **`openssl rand -hex 16`** (must be exactly 32 chars — xchacha20-poly1305 key length) |
| `HYDRA_SECRETS_SYSTEM` | encrypts Hydra DB rows | `openssl rand -hex 32` |
| `HYDRA_SECRETS_COOKIE` | signs Hydra cookies | `openssl rand -hex 32` |
| `HYDRA_PAIRWISE_SALT` | salt for pairwise OIDC subject IDs | `openssl rand -hex 32` |
| `LOGIN_UI_COOKIE_SECRET` | signs Login UI cookies | `openssl rand -hex 32` |
| `LOGIN_UI_CSRF_COOKIE_SECRET` | signs Login UI CSRF cookies | `openssl rand -hex 32` |
## DNS & TLS
Set up **two** DNS A/AAAA records pointing to your Hetzner host:
```
auth.example.com → host (Login UI + Kratos public + Consent, all same-origin)
hydra.example.com → host (Hydra public OAuth2/OIDC)
```
Traefik handles certs via the `myresolver` (or whatever you set in `TRAEFIK_CERTRESOLVER`). The cookie domain must be a parent that covers all three — set `COOKIE_DOMAIN=.example.com`.
## Setting up the social providers
The redirect URI in every provider's console is always:
```
https://${LOGIN_UI_HOST}/self-service/methods/oidc/callback/
```
`` is `google`, `pocket-id`, `github`, or `gitlab`. Kratos rejects providers with null/empty `client_id` or `client_secret` at startup, so every configured provider must have valid credentials. To temporarily disable one, remove its block from `config/kratos/kratos.yml.tmpl` (and the corresponding env vars from `docker-compose.yml`'s `kratos-config` env block).
| Provider | Console | Notes |
|---|---|---|
| **Google** | → OAuth client (Web) | Set `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. |
| **Pocket-ID** | Your Pocket-ID admin UI → create OIDC client | Set `POCKET_ID_ISSUER_URL` to your Pocket-ID base URL (must serve `.well-known/openid-configuration`), plus client id/secret. |
| **GitHub** | → New OAuth App | Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`. GitHub doesn't issue `email_verified`; we trust the email returned by `user:email` scope. |
| **GitLab** | (or your self-hosted instance) | Set `GITLAB_ISSUER_URL`, `GITLAB_CLIENT_ID`, `GITLAB_CLIENT_SECRET`. Scopes: `openid profile email`. |
To add another provider (Microsoft/Entra, Apple, Discord, …), add an entry under `selfservice.methods.oidc.config.providers` in `config/kratos/kratos.yml.tmpl`, ship a matching mapper jsonnet under `config/kratos/`, list it in `config/render.sh`, and add the env vars to the `kratos-config` init container in `docker-compose.yml`.
### Passkeys (WebAuthn)
Both passkey-passwordless and WebAuthn-2FA methods are enabled by default. `WEBAUTHN_RP_ID` must be a registrable suffix of every origin where you'll use passkeys — typically your cookie domain *without* the leading dot (e.g. `example.com` when `COOKIE_DOMAIN=.example.com`). Once a user logs in and clicks "Add passkey" in the settings UI, they can use it for subsequent logins on any subdomain under `WEBAUTHN_RP_ID`.
## Invitations: pre-create identities + send a 1h link
The default sign-in surface is OIDC + passkey only — no password or email-magic-code login. So practically the only way someone gets in is either (a) you invite them, or (b) they already have a Google/GitHub/etc. account that maps to an existing Kratos identity (which doesn't happen by default — see below). Either way, invitation is the primary onboarding path.
Use the `invite` CLI. It hits Kratos's admin API on the internal Docker network — run it on the host:
```bash
docker run --rm --network=ory_internal \
-e KRATOS_ADMIN_URL=http://kratos:4434 \
ghcr.io/korjavin/ory-invite:latest \
alice@example.com outline notan
```
What happens:
1. The CLI creates a Kratos identity with `traits.email = alice@example.com` and `metadata_admin.groups = ["outline-users", "notan-users"]`. Groups deliberately live under `metadata_admin` (not `traits`) so the user can NOT self-edit them via the Settings page.
2. It generates a Kratos recovery link valid for `KRATOS_RECOVERY_LIFESPAN` (default 1h).
3. It prints the link.
You forward the link to Alice (Telegram, email, however). When she clicks it:
1. Kratos validates the recovery token → creates a session.
2. She lands on `/settings`, where she can pick any combination of: link Google / GitHub / GitLab / Microsoft / Pocket-ID, add a passkey, set a password.
3. All of those credentials attach to the **same** identity, with `groups = ["outline-users", "notan-users"]` already set. Subsequent logins via any of those methods land on the same record.
If she doesn't click within an hour, the link expires — re-run the `invite` command to mint a fresh one (the identity is still there, just dormant).
Flags:
```
invite [flags] [app2 ...]
--expires-in 1h # how long the link is valid
--first "Alice" # optional first name
--last "Doe" # optional last name
--extra-groups admins # additional groups beyond -users
```
## Registering your apps as Hydra OAuth2 clients
There is no Hydra admin UI — you create clients via its admin API. Easiest way is to `docker exec` into the Hydra container and use the bundled CLI.
Each app gets its own Hydra client with `metadata.required_groups` listing the Kratos groups whose members may use it. The custom consent service enforces this: anyone not in at least one of those groups is rejected at consent time, before they ever see the app.
Example: Outline, gated on `outline-users`:
```bash
docker exec -it ory-hydra hydra create client \
--endpoint http://localhost:4445 \
--name "Outline" \
--grant-type authorization_code,refresh_token \
--response-type code,id_token \
--scope openid,offline,profile,email,groups \
--redirect-uri https://outline.example.com/auth/oidc.callback \
--token-endpoint-auth-method client_secret_basic \
--metadata '{"required_groups":["outline-users","admins"]}'
```
> Don't pass `--skip-consent`. The consent service must run on every request so it can enforce `required_groups` and inject `groups` into the token.
Hydra prints the generated `client_id` and `client_secret` — paste them into Outline's env vars.
In Outline (or any app), the OIDC discovery URL is:
```
https://hydra.example.com/.well-known/openid-configuration
```
Outline-specific env mapping:
```
OIDC_AUTH_URI=https://hydra.example.com/oauth2/auth
OIDC_TOKEN_URI=https://hydra.example.com/oauth2/token
OIDC_USERINFO_URI=https://hydra.example.com/userinfo
OIDC_DISPLAY_NAME=Sign in
OIDC_SCOPES=openid profile email
```
### Changing required_groups later
You can update a client's metadata without recreating it:
```bash
docker exec -it ory-hydra hydra update client \
--endpoint http://localhost:4445 \
--metadata '{"required_groups":["outline-users","admins","editors"]}'
```
Changes take effect on the next consent (i.e. the next time a user logs in fresh). Users who already have a refresh token keep working until it expires; revoke their tokens via `hydra revoke token` if you need an immediate cutoff.
## The consent service in one paragraph
`consent/` is a ~250-line Go service that owns the `/consent` URL on `auth.example.com`. On every consent request it:
1. Asks Hydra for the consent challenge details (`/admin/oauth2/auth/requests/consent`).
2. Asks Kratos for the identity (`/admin/identities/`) — pulls `metadata_admin.groups` (admin-only, not user-editable), plus `traits.email`, `traits.name`.
3. Reads `client.metadata.required_groups`. If non-empty and the user is in none of them → reject. Otherwise → accept.
4. On accept, copies `groups` into both `id_token.groups` and `access_token.groups`, plus standard email/name claims.
Auto-accept (no consent screen) because every Hydra client is first-party (your apps). If you ever expose Hydra to third-party apps, add a confirmation page here.
## Managing users & groups
Kratos has **no built-in admin UI**. For day-to-day work use the `invite` CLI above. For other operations, talk to the admin API on `:4434` (internal-only) directly:
```bash
# List identities
docker exec -it ory-kratos kratos list identities --endpoint http://localhost:4434
# Change someone's groups (replaces the list).
# Groups live under metadata_admin — admin-only, not user-editable from Settings.
docker exec -it ory-kratos kratos patch identity --endpoint http://localhost:4434 \
-p '[{"op":"replace","path":"/metadata_admin/groups","value":["admin","outline-users","forgejo-users"]}]'
# Revoke all of someone's sessions immediately (e.g. after offboarding)
docker exec -it ory-kratos kratos delete identity --endpoint http://localhost:4434
```
The `groups` array is the only thing the consent service consults — change it and the next consent (after re-login or token refresh) reflects the new permissions. To force an immediate cutoff, also revoke their Hydra refresh tokens: `docker exec -it ory-hydra hydra revoke token --endpoint http://localhost:4445 `.
If you want a clickable UI later, drop in a community admin tool — e.g. — pointed at the same internal Kratos admin URL. Keep it behind oauth2-proxy or your VPN; never expose the admin port publicly.
### Group naming convention
The `invite` CLI assigns groups as `-users` (e.g. `outline-users`, `notan-users`). Match that in each Hydra client's `metadata.required_groups`. You can also add cross-cutting groups (`admins`, `editors`) — `--extra-groups admins` on the invite CLI, then list `admins` in `required_groups` for any app admins should be able to use.
## Deploy in Portainer
1. **Create the Traefik network** if it doesn't exist:
```bash
docker network create traefik_default
```
2. **Before first deploy, run the image-building workflows once manually:**
- `Vendor Ory Images to GHCR` — mirrors `oryd/kratos`, `oryd/hydra`, `oryd/kratos-selfservice-ui-node` to your GHCR namespace.
- `Build Custom Services` — builds `ory-consent`, `ory-invite`, and `ory-kratos-config` from this repo into GHCR.
After both succeed, five images exist under `ghcr.io//`: `kratos-vendor`, `hydra-vendor`, `kratos-selfservice-ui-node-vendor`, `ory-consent`, `ory-kratos-config`. (`ory-invite` exists too but is only used via `docker run` on demand.)
3. **Create the stack in Portainer** → "Repository" mode, point at this repo, branch `deploy`.
4. **Paste the env vars** from `.env.example` into Portainer's env panel (replace placeholder values).
5. Hit **Deploy**. Watch logs for `kratos-config`, then `kratos-migrate`, then `kratos`, `hydra-migrate`, `hydra`, `login-ui`, `consent` coming up in order.
6. Set up the Portainer **redeploy webhook**, copy its URL into the GitHub repo as the secret `PORTAINER_REDEPLOY_HOOK`. From then on, every push to `master` triggers a redeploy via the `deploy` branch.
## Vendoring images
`.github/workflows/vendor-images.yml` runs weekly (Mondays 04:00 UTC) and:
1. Pulls `oryd/kratos:v1.3.1`, `oryd/hydra:v2.3.0`, and `oryd/kratos-selfservice-ui-node:v1.3.1` from Docker Hub.
2. Re-pushes them as `ghcr.io//-vendor:latest`.
3. Logs the upstream digest, force-pushes the `deploy` branch, and pings the Portainer webhook.
Bump the upstream tags in `.github/workflows/vendor-images.yml` when new Ory releases come out, then run the workflow manually with **Run workflow**.
## First-boot smoke test
```bash
# 1. Discovery doc (public)
curl -s https://hydra.example.com/.well-known/openid-configuration | jq .issuer
# 2. Kratos health
curl -s https://auth.example.com/health/ready
# 3. Browser: visit https://auth.example.com/ → log in via Google or Pocket-ID
```
## Switching to Postgres later
Both Kratos and Hydra accept a `DSN` env var. Change `KRATOS_DSN` and `HYDRA_DSN` to a `postgres://...` URL, add a Postgres service to the compose file (or point at an existing one), and restart. The `*-migrate` containers will run any new migrations automatically on the next start.