Operator Manual

Prerequisites

  • Docker and Docker Compose

  • An OIDC-compliant identity provider (Keycloak, Entra ID, Google, or any IDP that supports Authorization Code + PKCE)


Quick Start

Create a docker-compose.yml:

services:
  api:
    image: ghcr.io/exhuma/docroot/backend:1.0.0
    environment:
      DOCROOT_API_DATA_ROOT: /data
      DOCROOT_API_OAUTH_JWKS_URL: ${DOCROOT_API_OAUTH_JWKS_URL}
      DOCROOT_API_OAUTH_AUDIENCE: ${DOCROOT_API_OAUTH_AUDIENCE}
    volumes:
      - docroot_data:/data
  web:
    image: ghcr.io/exhuma/docroot/nginx:1.0.0
    ports:
      - "80:80"
    environment:
      DOCROOT_WEB_OIDC_ISSUER: ${DOCROOT_WEB_OIDC_ISSUER}
      DOCROOT_WEB_OIDC_CLIENT_ID: ${DOCROOT_WEB_OIDC_CLIENT_ID}
    volumes:
      - docroot_data:/data
volumes:
  docroot_data:

Fill in the environment values for your IDP (see the Environment Variables section below), then start the stack:

docker compose up -d

Open http://localhost in a browser.


Environment Variables

API container (DOCROOT_API_ prefix)

Set them in deploy/compose/.env or pass them directly to the API container.

Variable

Default

Description

DOCROOT_API_DATA_ROOT

/data

Filesystem path for stored data

DOCROOT_API_OAUTH_JWKS_URL

(empty)

JWKS endpoint for JWT validation (https://… or file://…)

DOCROOT_API_OAUTH_AUDIENCE

(empty)

Expected aud claim. Empty disables audience validation.

DOCROOT_API_OAUTH_CA_BUNDLE

(empty)

Path to a PEM CA cert/bundle for verifying the JWKS endpoint. Set when the IDP uses an internal or self-signed CA.

DOCROOT_API_OAUTH_VERIFY_SSL

true

Set false to disable TLS verification for the JWKS endpoint. Not for production use. A warning is logged on startup.

DOCROOT_API_OAUTH_ROLE_EXTRACTOR

keycloak

Role extractor. Only keycloak is shipped in this release.

DOCROOT_API_CORS_ORIGINS

*

Comma-separated allowed origins, or *.

DOCROOT_API_COOKIE_SECURE

false

Set true on HTTPS deployments.

DOCROOT_API_LOG_LEVEL

INFO

DEBUG / INFO / WARNING / ERROR

DOCROOT_API_ZIP_MAX_FILES

500

Maximum files in an uploaded ZIP.

DOCROOT_API_ZIP_MAX_EXTRACTED_MB

500

Maximum extracted ZIP size in MB.

nginx/UI container (DOCROOT_WEB_ prefix)

Variable

Default

Description

DOCROOT_WEB_OIDC_ISSUER

(empty)

OIDC issuer URL. Empty disables the Login button.

DOCROOT_WEB_OIDC_CLIENT_ID

(empty)

Public client ID for the browser login flow.


Volume

Mount a single host directory at /data. All data is stored there; the container is stateless.

volumes:
  - /your/host/path:/data

Kubernetes

The nginx image accepts two environment variables to control where it routes API requests. This is needed when the container hostname api is not resolvable (e.g. single-pod deployments where both containers share a network namespace):

Variable

Default

Description

API_HOST

api

Hostname or IP of the FastAPI container. Set to localhost for same-pod k8s deployments.

API_PORT

8000

TCP port of the FastAPI container.

Single-pod (sidecar) deployment: Both containers share the pod network namespace, so localhost resolves to the API container:

containers:
  - name: accelerator
    image: ghcr.io/exhuma/docroot/nginx:1.0.0
    env:
      - name: API_HOST
        value: localhost
  - name: api
    image: ghcr.io/exhuma/docroot/backend:1.0.0

Multi-pod deployment (separate Deployments + Service): Set API_HOST to the Kubernetes service name that fronts the API pods:

env:
  - name: API_HOST
    value: docroot-api  # name of the k8s Service for the backend

OIDC Authentication

Docroot uses a two-client authentication architecture:

  • UI (public client) — the browser performs an Authorization Code + PKCE flow. No client secret is used.

  • API (resource server) — the backend validates Bearer tokens by verifying their signature against the JWKS endpoint. It does not redirect users; it only accepts or rejects tokens.

  • Session cookie (docs bridge) — after login the UI obtains an HttpOnly session cookie that gates access to served documentation. No server-side session state is stored.

Configure both clients for every IDP:

# Back-end: where to fetch signing keys, and the expected audience
DOCROOT_API_OAUTH_JWKS_URL=https://<idp>/.well-known/jwks.json
DOCROOT_API_OAUTH_AUDIENCE=<api-audience>

# Front-end (nginx container): issuer and public client id
DOCROOT_WEB_OIDC_ISSUER=https://<idp>
DOCROOT_WEB_OIDC_CLIENT_ID=<public-client-id>

Register the following redirect URIs at your IDP. No client secret is needed (PKCE only).

Path

Purpose

https://<your-host>/oidc-callback

Full authorization-code redirect after login

https://<your-host>/oidc-silent

Silent-renew iframe callback

Audience validation

DOCROOT_API_OAUTH_AUDIENCE must match the aud claim in the access token. This is the trickiest part and differs between IDPs — see the provider-specific sections below.

JWKS key rotation

The backend fetches the JWKS endpoint automatically when an unknown kid arrives. Standard IDP key rotation needs no operator action.


Keycloak

1. Create a realm

In the Keycloak admin console, create a realm (e.g. docroot).

2. Back-end client (confidential)

  1. Clients → Create client; Client ID: docroot-api

  2. Client authentication: ON; Service accounts: ON

These steps give you the values for the API container:

DOCROOT_API_OAUTH_JWKS_URL=https://keycloak.example.com/realms/docroot/protocol/openid-connect/certs
DOCROOT_API_OAUTH_AUDIENCE=docroot-api

3. Front-end client (public)

  1. Clients → Create client; Client ID: docroot-ui

  2. Client authentication: OFF

  3. Valid redirect URIs:

    • https://docroot.example.com/oidc-callback

    • https://docroot.example.com/oidc-silent

  4. Web origins: https://docroot.example.com

These steps give you the values for the nginx container:

DOCROOT_WEB_OIDC_ISSUER=https://keycloak.example.com/realms/docroot
DOCROOT_WEB_OIDC_CLIENT_ID=docroot-ui

4. Roles

Create realm roles (e.g. docroot-editor) under Realm roles → Create role and assign them to users via Users → Role mappings. Reference the exact role name in namespace ACL entries.

Realm roles appear as-is (e.g. docroot-editor). Client-scoped roles are prefixed with the client ID and a slash (e.g. docroot-api/editor). Use the prefixed form in namespace ACL entries when granting access based on a client-specific role.

To limit which clients contribute roles, set one or both of:

Variable

Default

Description

DOCROOT_KEYCLOAK_CLIENT_ALLOWLIST

(empty)

Comma-separated list of client IDs to include. When non-empty, only roles from these clients are considered.

DOCROOT_KEYCLOAK_CLIENT_DENYLIST

(empty)

Comma-separated list of client IDs to exclude. Applied after the allowlist.

The allowlist is processed first (when non-empty, only listed clients pass). The denylist is then applied on top. A client ID that appears in both lists is excluded.

Getting the audience into the token

Keycloak does not add an aud claim for the back-end client by default. Two approaches:

Transparent (role-based): When a user is assigned a role that is scoped to docroot-api, Keycloak automatically includes docroot-api in the aud claim. No mapper needed — assigning the role is enough. The trade-off is that audience presence is tied to role assignment.

Explicit (audience mapper): Add a Hardcoded audience mapper on the docroot-ui client: Clients docroot-ui Client scopes dedicated Add mapper Hardcoded audience Included audience: docroot-api. This guarantees aud is always present regardless of roles. The trade-off is a small amount of manual configuration.


Microsoft Entra ID (Azure AD)

1. Register an application

  1. Azure AD → App registrations → New registration

  2. Redirect URI type: Single-page application (SPA); values: https://docroot.example.com/oidc-callback and https://docroot.example.com/oidc-silent

  3. Note the Application (client) ID and Directory (tenant) ID.

2. Expose an API

Under Expose an API, set the Application ID URI (e.g. api://<client-id>). This becomes your DOCROOT_API_OAUTH_AUDIENCE.

Token version: Entra ID issues v1 tokens by default. In the app Manifest, set "accessTokenAcceptedVersion": 2 to get v2 tokens; the JWKS URL below only works with v2.

DOCROOT_API_OAUTH_JWKS_URL=https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
DOCROOT_API_OAUTH_AUDIENCE=api://<client-id>
DOCROOT_WEB_OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
DOCROOT_WEB_OIDC_CLIENT_ID=<client-id>

Google

DOCROOT_API_OAUTH_JWKS_URL=https://www.googleapis.com/oauth2/v3/certs
DOCROOT_API_OAUTH_AUDIENCE=<google-client-id>
DOCROOT_WEB_OIDC_ISSUER=https://accounts.google.com
DOCROOT_WEB_OIDC_CLIENT_ID=<google-client-id>

In the Google Cloud Console: APIs & Services → Credentials → OAuth client ID; application type Web application; add both https://docroot.example.com/oidc-callback and https://docroot.example.com/oidc-silent as authorised redirect URIs.


Providers without native OIDC support

GitHub and Facebook do not expose a standards-compliant OIDC endpoint for browser user logins. To use them, run an OIDC-compliant proxy such as Keycloak (identity brokering), Dex, or Zitadel in front of them and point Docroot at the proxy.


Namespace ACL

Each namespace has an access control list that you can manage via the Manage Access button (shield icon) in the UI, or through the ACL API described below.

Field

Description

creator

sub claim of the creator. Only this user may delete the namespace.

creator_display_name

Informational display name for the creator. Set automatically on login; may lag behind the IDP.

display_name

Human-readable label for the namespace shown in the UI.

versioning

Sort scheme: semver, calver, pep440, or a custom regex.

public_read

Allow unauthenticated read access to docs.

browsable

Allow unauthenticated callers to list the namespace and its projects/versions without granting doc access (default true).

Role read / write

Grant read or write access to users holding that role from the JWT. Matching is case-insensitive.

Note

ACL settings are persisted in a namespace.toml file inside each namespace directory on the host. You can inspect or edit this file directly — changes are picked up automatically without a restart. The file format is shown below for reference; see the Data Layout section for the storage path.

# Namespace configuration example.
#
# This file is managed via the web UI or the ACL API.
# See the operator manual for details.

creator = "alice@example.com"
# Human-readable label for the creator. Informational only; updated
# automatically when the creator logs in. May lag behind the IDP.
creator_display_name = "Alice Example"
# Human-readable display name for this namespace shown in the UI.
display_name = "My Project Docs"
versioning = "semver"

[access]
# Set to true to allow anyone to read docs without logging in.
public_read = false

# Allow the namespace to appear in listings even for unauthenticated
# users, without granting access to the documentation content.
browsable = true

[[access.roles]]
role = "docroot-editor"
read = true
write = true

[[access.roles]]
role = "docroot-reader"
read = true
write = false

ACL API

All ACL mutations require a valid JWT with write access to the namespace.

# Read the current ACL (write access required)
curl -H "Authorization: Bearer $TOKEN" \
  https://docroot.example.com/api/namespaces/myns/acl

# Grant a role read+write access
curl -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"read": true, "write": true}' \
  https://docroot.example.com/api/namespaces/myns/acl/roles/docroot-editor

# Revoke a role
curl -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  https://docroot.example.com/api/namespaces/myns/acl/roles/docroot-editor

The Manage Access button (shield icon) in the UI provides a graphical alternative: it shows a table of the current user’s JWT roles with toggles for read/write permissions.

Ownership transfer

The creator field controls who may delete the namespace. If the original creator is no longer available, any user with write access can claim ownership:

curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  https://docroot.example.com/api/namespaces/myns/owner

After this call the caller becomes the new creator.


Data Layout

/data/
  namespaces/
    <namespace>/
      namespace.toml
      projects/
        <project>/
          versions/
            <version>/
              <locale>/
                index.html
                …
          refs/
            <refname> -> ../versions/<version>  (symlink)

Development Setup

To build from source, clone the repository first:

git clone https://github.com/exhuma/docroot.git
cd docroot

Then generate a test token:

task gen-token -- --sub alice --roles editor

See README.md and Taskfile.yml for the full local dev workflow.

Local Keycloak overlay

docker compose \
  -f deploy/compose/docker-compose.yml \
  -f deploy/compose/docker-compose.dev.yml \
  up

Both the browser and the API container reach Keycloak at http://keycloak.127.0.0.1.nip.io:8080.

Pre-loaded accounts:

Username

Password

Role

alice

alice

docroot-editor

bob

bob

docroot-reader

Admin: http://keycloak.127.0.0.1.nip.io:8080/admin (admin / admin)

For local development only. Do not expose to the internet.