This guide covers running, verifying, and troubleshooting Nova ID.
- API must only be reachable via Oathkeeper. Do not expose the API (port 8080) to the public internet. All client traffic must go through the gateway; the API trusts headers (
X-User-ID, etc.) injected by Oathkeeper. See SECURITY_REPORT.md. - Hydra Admin API: The API calls Hydra Admin (login/consent accept) from the server. Set
HYDRA_ADMIN_URL=http://hydra:4445(or your internal Hydra Admin URL). Do not expose Hydra Admin (port 4445) to the public internet. In local dev, the ruleinternal-hydra-admin(if used) must only be reachable from the API service on the internal network. - Secrets: Generate all secrets (Kratos, Hydra) with e.g.
openssl rand -base64 32. Never deploy with placeholder values. See.env.example.
| File | Purpose |
|---|---|
docker-compose.yml |
Base stack (Ory, API, frontends). No local-proxy. |
docker-compose.local.yml |
Local override: adds local-proxy (port 80) for auth.ory.localhost, admin.ory.localhost, etc. Development only — never used in production. |
docker-compose.production.yml |
Production override: Traefik labels, no exposed ports. Does not include local-proxy. |
- Dev (ports 5173/5174/5175):
docker compose up -d - Dev with local domains + local-proxy:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -dor./start-local.sh - Production:
docker compose -f docker-compose.yml -f docker-compose.production.yml up -dor./start-production.sh
# Start all services
docker compose up -d
# Stop all services
docker compose down
# Stop and remove volumes (full reset)
docker compose down -vWait 30–60 seconds after up for migrations and services to be ready.
# Restart all
docker compose restart
# Restart a single service
docker compose restart kratos
docker compose restart api
docker compose restart oathkeeper# List running services
docker compose ps
# Logs (all services, follow)
docker compose logs -f
# Logs for one service
docker compose logs -f api
docker compose logs -f oathkeeper
docker compose logs -f kratos
# Last N lines
docker compose logs --tail 100 api| Service | Endpoint | Expected |
|---|---|---|
| Kratos | GET http://localhost:4434/health/ready |
{"status":"ok"} |
| Keto Read | GET http://localhost:4466/health/ready |
{"status":"ok"} |
| Keto Write | GET http://localhost:4467/health/ready |
{"status":"ok"} |
| Hydra | GET http://localhost:4445/health/alive |
{"status":"ok"} |
| Oathkeeper | GET http://localhost:4456/health/alive |
{"status":"ok"} |
| API | GET http://localhost:8080/health |
{"status":"ok",...} |
curl -s http://localhost:4434/health/ready | jq .
curl -s http://localhost:4466/health/ready | jq .
curl -s http://localhost:4456/health/alive | jq .
curl -s http://localhost:8080/health | jq .# Public (no auth)
curl -s http://localhost:4455/api/health | jq .
curl -s http://localhost:4455/api/public | jq .
# Protected (requires session cookie from browser login)
# Use browser dev tools to copy cookie, then:
curl -s -b "ory_kratos_session=..." http://localhost:4455/api/protected | jq .Nova ID uses Ory Keto's OPL model. The namespaces are User, Platform, and App. Platform-level admin access is granted via the relation tuple Platform:nova#admins@user:<kratos-id>.
The keto-seed compose service runs automatically after keto and kratos are healthy on every docker compose up. It is idempotent and one-shot (restart: "no"). It reads all Kratos identities whose metadata_public.role == "platform_admin" and writes the corresponding Platform:nova#admins tuples to Keto.
Note:
metadata_public.roleis the authoritative field — the identity schema blockstraits.roleviaadditionalProperties: false. Keto is the source of truth after seeding;metadata_public.roleis only used for bootstrapping.
# Run the seed script manually (joins the internal network automatically):
docker compose run --rm keto-seed
# Or with host-exposed ports (only when temporarily debugging with ports exposed):
KETO_WRITE_URL=http://localhost:4467 \
KRATOS_ADMIN_URL=http://localhost:4434 \
docker compose run --rm keto-seed./scripts/assign-platform-admin-to-user.sh user@example.comThe script updates metadata_public.role in Kratos and writes the Platform:nova#admins tuple directly to Keto in one step — no separate keto-seed run is required. Re-running docker compose run --rm keto-seed is safe (idempotent) but only needed for reconciliation (e.g., after a Keto data reset).
The correct check endpoint for OPL computed permissions in Keto v25.4.0 uses the legacy GET check endpoint — it fully resolves OPL permits:
# Check if a user has the computed permission 'administer' on Platform:nova
# (resolves the OPL permit, not just the direct relation)
docker run --rm --network nova-id-ory-internal curlimages/curl:latest -s \
"http://keto:4466/relation-tuples/check?namespace=Platform&object=nova&relation=administer&subject_id=user:<KRATOS_IDENTITY_ID>"
# Response: {"allowed":true} (admin) or {"allowed":false} (non-admin)
# Check manage_users permission:
docker run --rm --network nova-id-ory-internal curlimages/curl:latest -s \
"http://keto:4466/relation-tuples/check?namespace=Platform&object=nova&relation=manage_users&subject_id=user:<KRATOS_IDENTITY_ID>"
# List all Platform tuples:
docker run --rm --network nova-id-ory-internal curlimages/curl:latest -s \
"http://keto:4466/relation-tuples?namespace=Platform" | jq .Task 5 note (Oathkeeper): The verified endpoint for Oathkeeper's Keto authorizer is:
GET http://keto:4466/relation-tuples/check?namespace=Platform&object=nova&relation=administer&subject_id=user:{subject}→{"allowed":true|false}
# List relation tuples (run from host — requires ports temporarily exposed, or use docker run form above)
docker run --rm --network nova-id-ory-internal curlimages/curl:latest -s \
"http://keto:4466/relation-tuples?namespace=Platform" | jq .
# By user
USER_ID="<kratos-identity-id>"
docker run --rm --network nova-id-ory-internal curlimages/curl:latest -s \
"http://keto:4466/relation-tuples?subject_id=user:${USER_ID}" | jq ../scripts/test-stack-comprehensive.shCovers Kratos, Keto, Hydra, Oathkeeper, and permission layout.
# Test email sending
./scripts/test-email-sending.sh
# With real user (from Kratos)
./scripts/test-email-with-real-user.shInbox: Mailpit at http://localhost:8025.
./scripts/setup-hydra-test-client.shThen use the Auth UI or Hydra flows to obtain tokens and call APIs.
# Health
curl -s http://localhost:8080/health | jq .
# Public
curl -s http://localhost:4455/api/public | jq .
# Kratos identities (admin; ensure 4434 exposed)
curl -s http://localhost:4434/admin/identities | jq '.[] | {id, email: .traits.email, role: .traits.role}'| App | URL |
|---|---|
| Auth UI | http://localhost:5173 |
| Admin | http://localhost:5174 |
| Test app | http://localhost:5175 |
| Mailpit | http://localhost:8025 |
| API (gateway) | http://localhost:4455 |
-
Check
docker compose logsfor the failing service (oftenkratos,hydra,keto, orapi). -
Ensure PostgreSQL is up and migrations have run (
kratos-migrate,hydra-migrate,keto-migrate). -
Try a full restart:
docker compose down && docker compose up -dWait 1–2 minutes, then re-check health.
The request matches more than one Oathkeeper access rule. Check config/oathkeeper/rules.local.json / config/oathkeeper/rules.production.json: URL patterns must not overlap (e.g. /api/health vs /api/*). Public routes should be more specific or listed first.
- Browser: Ensure you’re logged in and the session cookie is sent (same origin or correct Oathkeeper URL). Clear cookies and log in again.
- curl: Use
-b "ory_kratos_session=..."with a valid session cookie obtained from the browser.
User must have platform_admin. Assign it:
./scripts/assign-platform-admin-to-user.sh user@example.comFrontend must call the API via the same origin (e.g. Vite proxy to Oathkeeper) or Oathkeeper must allow the frontend origin. Check vite.config.js proxy and Oathkeeper CORS/config.
- Development: check Mailpit (http://localhost:8025).
- Verify Kratos courier config in
config/kratos/kratos.local.yml(SMTP / Mailpit). - Run
./scripts/test-email-sending.shto confirm sending.
# PostgreSQL ready
docker compose exec postgres pg_isready -U postgres
# Connect
docker compose exec postgres psql -U postgres -c "\l"If you use custom DB names/users, adjust accordingly. For a clean slate, docker compose down -v and then up -d (this removes volumes).
From project root:
make help # List targets
make dev # Start dev stack (docker compose up -d)
make dev-local # Start dev with local domains + local-proxy (docker-compose.local.yml)
make prod # Start production stack (docker-compose.production.yml)
make stop # Stop dev stack
make stop-prod # Stop production stack
make logs # Follow logs (dev)
make ps # Service status (dev)
make health # Basic health checks
make setup-permissions # Run setup-all-permissions.sh
make clear-permissions # Run clear-all-permissions.sh
make test # Run test-stack-comprehensive.sh
make test-email # Run test-email-sending.sh
make test-email-real # Run test-email-with-real-user.sh
make setup-hydra-client # Run setup-hydra-test-client.sh
make generate-env # Run generate-env.shWhen a user logs in from the app (OAuth) and then uses the browser Back button, they may land on auth URLs with a stale return_to=...hydra-callback.... The frontend (auth) handles this:
- HydraCallback and Consent: on stale/used challenge, redirect to the app.
- Login (
session_already_availableor noreturn_to): redirect to the app (never to admin).
The local-proxy (nginx) in config/nginx/local-proxy.conf is only for exposing local domains in development; it is not modified for this behavior. In production with Traefik, if you want to avoid the user seeing a 400 when they Back into GET /api/self-service/login/browser?return_to=...hydra-callback..., you can add a redirect rule in Traefik (e.g. when Sec-Fetch-Mode: navigate and return_to contains hydra-callback, respond with 302 to the app). Otherwise, the frontend redirects from HydraCallback/Consent/Login are enough for most cases.
Deploys to the production host use a registry-first pull model.
Images are pre-built by GitHub Actions on tag push; the server only pulls and
restarts containers — it never runs docker build.
Connection details (host, SSH port, deploy user) are stored in GitHub Actions
secrets POLARIS2_HOST, POLARIS2_PORT, and POLARIS2_USER. The deploy
workflow SSHes in automatically — no manual SSH needed for routine releases.
Push a version tag from main:
git checkout main
git pull
git tag v1.2.0
git push origin v1.2.0The tag push triggers release.yml only. Deploy is chained after the build,
not triggered independently, so the two can never race:
release.ymlruns 5 parallel jobs — one per image — each building, Trivy-scanning (fail on CRITICAL/HIGH), and pushing the immutable version tag +:latestto Docker Hub.- When all 5 build jobs succeed,
release.yml's finaldeployjob callsdeploy.ymlas a reusable workflow (needs:the 5 builds +uses:). deploy.ymlSSHes into the production host and runsscripts/deploy-prod.sh v1.2.0.
Because the deploy job needs all 5 builds, deploy runs only after every
image is built and pushed — deploy-prod.sh can safely docker pull the tag
with no build/pull race.
The deploy script: pulls images → docker compose up -d --no-build →
restarts Oathkeeper → smoke-tests 4 URLs → writes .deploy-version.
On smoke failure: auto-rollback to the previous version.
Run deploy.yml directly (it never auto-runs on tag push — it only runs as
release.yml's chained job or via this manual dispatch).
Option A — workflow_dispatch (preferred; leaves an audit trail in GitHub):
- Go to Actions → Deploy to Production → Run workflow
- Enter the target tag (e.g.
v1.1.0) in thereffield. The images for that tag must already exist on Docker Hub (they do for any previously released tag).
Option B — direct SSH (emergency only, requires the deploy key):
ssh -i ~/.ssh/nova_id_deploy -p <POLARIS2_PORT> <POLARIS2_USER>@<POLARIS2_HOST> v1.1.0The SSH command= restriction on the server only allows running deploy-prod.sh;
the version tag is passed as SSH_ORIGINAL_COMMAND.
SSH into the production host and inspect:
# In ~/deploy/nova-id-deploy
cat .deploy-version
docker compose --env-file .env.production -f docker-compose.production.yml psAll four must return HTTP 200 for a deploy to be considered healthy:
| URL | Service |
|---|---|
https://id.cativo.dev/health/alive |
Oathkeeper gateway alive |
https://id.cativo.dev/.well-known/openid-configuration |
OIDC metadata |
https://id.cativo.dev/api/health |
BFF (NestJS) health |
https://app.cativo.dev/api-test/health |
demo-api health |
After the very first deploy to a new server, Ory databases must be initialised.
This is not done by deploy-prod.sh — use:
# On the production host, in ~/deploy/nova-id-deploy
bash scripts/bootstrap-prod.sh --first-boot
bash scripts/deploy-prod.sh v1.0.0
bash scripts/bootstrap-prod.sh --assign-admin carlos@example.comThe Oathkeeper id_token signing key lives at
config/oathkeeper/id_token.jwks.json. Rotating it invalidates all active
sessions. Never rotate on a routine deploy.
- Plan a maintenance window and notify users.
- On the production host:
bash scripts/generate-jwks.sh(generates new key). chmod 644 config/oathkeeper/id_token.jwks.json- Push the new JWKS file in a tagged release and deploy normally.
- All logged-in users will be logged out — expected.
deploy-prod.sh aborts if JWKS is missing — it never auto-generates it.
# After creating a new PAT at hub.docker.com:
gh secret set DOCKERHUB_TOKEN --body "<new-token>"- Getting Started — Install and first login
- Architecture — System design
- Auth & RBAC — Roles and permissions
- Scripts README — All scripts