> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Deployment & SSO

> Single sign-on through Clerk and self-hosted vs. ModuleX-managed deployment for Enterprise: the entitlement flags that unlock them, just-in-time user provisioning, the production secrets a self-host requires, and the startup security gate.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

This page covers the two Enterprise-only capabilities in this group: **single sign-on (SSO)** for your team, and **self-hosted deployment** of the ModuleX backend. Both are gated by per-plan entitlement flags that are set only on the **Enterprise** plan; everything below describes how each capability works against the verified backend behavior, what it requires, and which specifics are not pinned in source and are marked TBD.

Authentication mechanics referenced here are documented in full on [the authentication model page](/security/authentication); credential and secret encryption is documented on [the data security & encryption page](/security/data-encryption).

<Note>
  **Both capabilities are Enterprise entitlements.** In the plan configuration, `feature.sso` and `feature.self_host` are `true` only on the **Enterprise** plan and `false` on Free, Pro, and Max. They are surfaced as account feature flags; they are not toggles you set yourself. Talk to the team to enable them — see [contact sales](/enterprise/contact-sales).
</Note>

## How SSO works in ModuleX

ModuleX has **one identity provider: Clerk.** The backend only ever *verifies* Clerk-issued JWTs — there are no first-party `/auth/login`, `/register`, `/logout`, or `/refresh` routes. The auth provider is read from `modulex.toml` as `[auth].provider`, which defaults to `"clerk"` and is the only configuration key that file consumes:

```toml modulex.toml theme={null}
[auth]
provider = "clerk"
```

Because every human sign-in flows through Clerk, **enterprise SSO is delivered as a Clerk enterprise connection** (for example SAML or OIDC against your identity provider). When an SSO user signs in, Clerk mints a JWT, and the ModuleX backend verifies that JWT exactly as it would any other Clerk token. No separate SSO code path exists inside the ModuleX backend.

<Warning>
  **SAML/OIDC connection details are configured in the Clerk dashboard, not in this repository — TBD.** The ModuleX source contains no SAML, OIDC, or SCIM implementation; the `feature.sso` flag is an entitlement signal, not enforcement code. The exact list of supported identity providers, the SAML/OIDC connection setup steps, the metadata/ACS URLs, and whether SCIM directory sync is offered are not pinned in source. Treat those specifics as **TBD** and confirm them with the ModuleX team before relying on them.
</Warning>

### What the backend verifies on every request

Whether a user arrives via SSO or ordinary Clerk sign-in, the token they carry is verified the same way. The verifier requires both `CLERK_JWKS_URL` and `CLERK_ISSUER` to be set; if either is missing, verification returns no claims and the request is rejected.

<ParamField path="CLERK_JWKS_URL" type="string" required>
  JWKS endpoint used to verify the JWT signature. JWKS responses are cached in-process with a 300-second TTL. Missing value disables JWT verification entirely.
</ParamField>

<ParamField path="CLERK_ISSUER" type="string" required>
  Expected `iss` claim. The token is decoded with `issuer=CLERK_ISSUER`.
</ParamField>

The claims read downstream are `sub` (the Clerk user id), `email`, `azp`, and `sid`. When the configured provider is `clerk`, the effective organization role is sourced from the JWT's `metadata.role`. See [the authentication model page](/security/authentication) for the full header-by-header breakdown, including the `Authorization: Bearer` forms and the Socket.io handshake.

### Just-in-time user provisioning

ModuleX provisions accounts **just in time (JIT)** on the first valid JWT for an unknown user. This is what makes SSO usable without manual seat setup: a user who signs in through your identity provider for the first time is created automatically.

<Steps>
  <Step title="First authenticated request arrives">
    A Clerk JWT for a user with no existing ModuleX record reaches the backend.
  </Step>

  <Step title="The user record is created">
    A `User` is created with the user-level role `USER`. (User-level roles are `USER` and `SUPER_ADMIN`; these are distinct from organization roles.)
  </Step>

  <Step title="A default organization and owner membership are seeded">
    A personal default organization is created, and the new user is added as its `owner`. The flow is idempotent and concurrent-safe, guarded by a row lock so a webhook-plus-JIT race cannot double-provision.
  </Step>
</Steps>

<Warning>
  **There is no JIT path for API keys.** A `mx_live_*` API key cannot create a user — key creation is itself JWT-gated (`POST /api-keys` requires a signed-in user). So SSO/JIT provisioning applies to human sign-ins only; programmatic callers must already correspond to a provisioned user. See [the SDKs overview](/sdks/overview) for the programmatic path.
</Warning>

### Organization roles after SSO sign-in

Once provisioned, a user acts inside an organization under an organization role. The live roles are **`owner`** and **`admin`** only.

<Warning>
  **The `member` role is retired.** It was retired on the backend and may still appear on old organization rows, in legacy invitation constraints, and in the realtime read-gate, but the REST edge rejects it everywhere (invite and role-update schemas enforce `admin` only). Document and plan around `owner`/`admin`. Note also that the agentic surfaces — Composer, Assistant, and schedules — require **owner/admin**, despite an older in-code comment suggesting any member can use the Assistant. Full detail is on [the roles & permissions page](/security/roles-permissions).
</Warning>

`owner` is assignable only at organization creation. Invitations and role updates are constrained to `admin` at the API edge. Organization scope itself is carried by the `X-Organization-ID` header on REST/SDK calls and by the Socket.io handshake `auth.organizationId` field on the realtime surface — see [org context & X-Organization-ID](/security/org-context).

<MediaEmbed id="MX-MEDIA-4320" type="image" caption={"SSO sign-in and just-in-time provisioning sequence diagram."} />

## Deployment options

ModuleX runs in two shapes. The default is **ModuleX-managed** (cloud); **self-hosted** is an Enterprise-only entitlement.

<CardGroup cols={2}>
  <Card title="ModuleX-managed (cloud)" icon="cloud">
    The default. ModuleX operates the backend, realtime server, database, and secret storage. Available on every plan. Authentication, secrets, and the production security gate are managed for you.
  </Card>

  <Card title="Self-hosted" icon="server">
    Enterprise only (`feature.self_host = true`). You operate the ModuleX backend in your own environment and supply the required configuration and secrets described below. Talk to the team via [contact sales](/enterprise/contact-sales).
  </Card>
</CardGroup>

<Note>
  **Self-hosting is gated by the `self_host` entitlement.** In the plan configuration, `feature.self_host` is `true` only on Enterprise. There is no public self-service installer documented in source. The packaging, container images, and orchestration topology for a customer self-host are **TBD** — confirm the supported deployment artifacts with the ModuleX team.
</Note>

### Self-host requirements

A self-hosted backend sources its configuration through a single settings layer, resolving each secret **environment variable first, then Azure Key Vault, then default**. The following are the configuration values a production self-host depends on. Types and defaults are as defined in the backend configuration.

<ParamField path="ENVIRONMENT" type="string" default="development" required>
  Canonical environment name; drives all production-only enforcement. Aliases are normalized: `prod` becomes `production`, `stage` becomes `staging`. Set to `production` for a real deployment. The values `production` and `staging` are the two that trigger the hard security gate below.
</ParamField>

<ParamField path="ENCRYPTION_KEY" type="string (≥32 chars)" required>
  Symmetric key used to encrypt integration credentials. **Required in `production`/`staging`** (minimum length 32 = 256-bit). In development it defaults to an empty string and the app logs an insecure-key warning. Generate with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`.

  <Warning>
    If this key changes, all previously encrypted credential data becomes unrecoverable. Treat it as permanent for the lifetime of the data.
  </Warning>
</ParamField>

<ParamField path="API_KEY_PEPPER" type="string (≥32 chars)" required>
  A server-side secret mixed into API-key hashing to harden the one-way fingerprint. **Required in `production`/`staging`** (minimum length 32). Generate with `python3 -c "import secrets; print(secrets.token_hex(32))"`.

  <Warning>
    `API_KEY_PEPPER` is production-required. Set it explicitly when you self-host — the startup security gate refuses to start without it.
  </Warning>
</ParamField>

<ParamField path="DATABASE_URL" type="string" default="(empty)" required>
  PostgreSQL DSN. **Required in `production`/`staging`** (empty value is an error in those environments, a warning otherwise). Use the async driver form, for example `postgresql+asyncpg://user:pass@db:5432/modulex`.
</ParamField>

<ParamField path="REDIS_URL" type="string" default="redis://localhost:6379">
  Redis connection string. Used for caching, rate limiting, and realtime coordination.
</ParamField>

<ParamField path="CLERK_JWKS_URL" type="string" default="(empty)" required>
  JWKS endpoint for verifying Clerk JWTs. Required for any human sign-in (including SSO). Missing value disables JWT verification.
</ParamField>

<ParamField path="CLERK_ISSUER" type="string" default="(empty)" required>
  Expected JWT `iss` claim; must match the issuer your Clerk instance mints.
</ParamField>

<ParamField path="ADMIN_DASHBOARD_API_KEYS" type="comma-separated list" default="[]">
  Gate for the platform `/admin/*` cross-organization monitoring routes. Comma-separated to allow zero-downtime rotation. An empty list makes the admin gate **fail closed** (no access). Generate with `openssl rand -hex 32`.
</ParamField>

<ParamField path="AZURE_KEY_VAULT_URL" type="string" default="(unset)">
  When set, secrets are sourced from Azure Key Vault after the environment variable lookup. Authentication uses the Container App system-assigned managed identity with the "Key Vault Secrets User" role. When unset (local/dev), only environment variables are read.
</ParamField>

<Expandable title="Optional and advanced configuration">
  <ParamField path="BASE_URL" type="string" default="http://localhost:8000">
    Server base URL used to build OAuth callback URLs.
  </ParamField>

  <ParamField path="FRONTEND_URL" type="string" default="http://localhost:3000">
    Frontend origin.
  </ParamField>

  <ParamField path="ALLOWED_HOSTS" type="comma-separated list" default="*">
    CORS allowed hosts; split on commas and stripped.
  </ParamField>

  <ParamField path="OAUTH_FRONTEND_REDIRECT_BASE" type="string" default="https://app.modulex.dev">
    Frontend base the backend redirects to after an OAuth token exchange.
  </ParamField>

  <ParamField path="OAUTH_FRONTEND_CALLBACK_PATH" type="string" default="/oauth/callback">
    Frontend landing path after OAuth; reads `status`, `integration`, `credential_id`, `error_code`, and `message` query parameters.
  </ParamField>

  <ParamField path="API_KEY_DEFAULT_RATE_LIMIT" type="integer" default="60">
    Per-key requests per minute.
  </ParamField>

  <ParamField path="API_KEY_USER_RATE_LIMIT" type="integer" default="300">
    Total per-user requests per minute.
  </ParamField>

  <ParamField path="API_KEY_MAX_PER_USER" type="integer" default="10">
    Maximum number of API keys per user.
  </ParamField>

  <ParamField path="HEALTH_CHECK_API_KEY" type="string" default="(empty)">
    API key for the read-only diagnostics endpoint. When empty, the endpoint is disabled and returns `503`.
  </ParamField>

  <ParamField path="LOAD_CONFIG" type="string" default="medium">
    Selects a capacity preset (`small`, `medium`, `large`, `enterprise`, `azure_e8ds_v5`, `azure_e8ds_v5_peak`). An unknown name falls back to per-variable overrides such as `MAX_CONCURRENT_EXECUTIONS` (default `50`), `REQUEST_TIMEOUT` (default `30.0`), and `MAX_QUEUE_SIZE` (default `200`).
  </ParamField>
</Expandable>

<Note>
  **Casing on the wire.** Environment variables are `UPPER_SNAKE_CASE`. When sourced from Azure Key Vault, secret names are `UPPER-KEBAB-CASE` (underscores become hyphens) — for example `ENCRYPTION_KEY` is stored as `ENCRYPTION-KEY` and `API_KEY_PEPPER` as `API-KEY-PEPPER`. The conversion is automatic.
</Note>

<Warning>
  **Key Vault rotation needs a restart.** The Key Vault secret resolver is cached for the process lifetime, so rotating a secret in the vault is not picked up until the process restarts. Environment-sourced values are likewise read once at startup.
</Warning>

### The production startup security gate

On startup, the backend validates the security-critical secrets. The behavior depends on `ENVIRONMENT`:

| Condition                              | `production` / `staging` | `development`                     |
| -------------------------------------- | ------------------------ | --------------------------------- |
| `ENCRYPTION_KEY` missing               | Startup fails            | Warning, continues with empty key |
| `ENCRYPTION_KEY` shorter than 32 chars | Startup fails            | Warning, continues                |
| `API_KEY_PEPPER` missing               | Startup fails            | Warning, continues (dev only)     |
| `API_KEY_PEPPER` shorter than 32 chars | Startup fails            | Warning, continues                |
| `DATABASE_URL` empty                   | Startup fails            | Warning, continues                |

In `production`/`staging`, any validation error prints `❌ SECURITY VALIDATION FAILED` and the process exits with code `1`. In development the process prints an insecure-settings warning and continues. On success it logs that security settings were validated.

### Minimal production environment

<CodeGroup>
  ```bash .env (self-host) theme={null}
  # Identity / encryption (REQUIRED in production, >=32 chars each)
  ENCRYPTION_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
  API_KEY_PEPPER="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"   # production-required; set explicitly

  ENVIRONMENT=production            # or "prod" -> normalized to production
  DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/modulex   # required in prod
  REDIS_URL=redis://cache:6379

  # Clerk auth (required for human sign-in, including SSO)
  CLERK_JWKS_URL=https://clerk.example.com/.well-known/jwks.json
  CLERK_ISSUER=https://clerk.example.com

  # Platform admin gate (fails CLOSED if empty); rotate via comma-separated list
  ADMIN_DASHBOARD_API_KEYS="$(openssl rand -hex 32)"
  ```

  ```bash .env (Key Vault variant) theme={null}
  # Same as above, but source the security secrets from Azure Key Vault.
  # Store them in the vault as ENCRYPTION-KEY and API-KEY-PEPPER (kebab-case).
  ENVIRONMENT=production
  DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/modulex
  REDIS_URL=redis://cache:6379
  CLERK_JWKS_URL=https://clerk.example.com/.well-known/jwks.json
  CLERK_ISSUER=https://clerk.example.com
  ADMIN_DASHBOARD_API_KEYS="$(openssl rand -hex 32)"

  # Enables Key Vault sourcing; ENCRYPTION_KEY / API_KEY_PEPPER resolved from the vault.
  AZURE_KEY_VAULT_URL=https://my-vault.vault.azure.net/
  ```
</CodeGroup>

<Note>
  **Operational details that are not pinned in source — TBD.** The process/worker runtime (host, port, worker count), container images and orchestration topology, backup/restore procedures, and any Celery or telemetry configuration are not documented in the configuration source. The backend exposes no `PORT`, `WORKERS`, `HOST`, or `SENTRY_*` environment variables, which suggests those are supplied by the process manager or compose stack rather than by application config. Confirm the supported runtime with the ModuleX team.
</Note>

## Provisioning and seats

User provisioning is JIT, as described above — new SSO users are created on first sign-in with an owner default organization. **Directory-driven provisioning and de-provisioning (SCIM) is not implemented in source and is TBD.**

Seat limits come from plan entitlements, not from a deployment setting:

| Plan       | Seat quota                 | Self-host | SSO |
| ---------- | -------------------------- | --------- | --- |
| Free       | 5                          | No        | No  |
| Pro        | Per-seat licensed          | No        | No  |
| Max        | Per-seat licensed          | No        | No  |
| Enterprise | Custom / per-seat licensed | Yes       | Yes |

A `seats` quota of `null` means the plan is per-seat licensed (billed, not capped at a fixed number). Enterprise sets `feature.self_host` and `feature.sso` to `true`; all other plans set both to `false`. For the full plan comparison and the credit and rate-limit allowances, see [plans & pricing](/billing/plans).

<Warning>
  **Enterprise annual pricing is unresolved — present both figures.** The marketing site advertises Pro annual at `$240/yr` and Max annual at `$960/yr` (a 20% discount), while the backend Stripe configuration prices the annual SKUs at `$300/yr` and `$1,200/yr` (full monthly × 12, no discount). The authoritative annual price is an open question; do not pick a winner here. Always confirm the actual charge at checkout, and see [plans & pricing](/billing/plans) for the canonical figures.
</Warning>

## Requirements at a glance

<AccordionGroup>
  <Accordion title="To use SSO" icon="key">
    * An Enterprise plan with `feature.sso = true`.
    * A Clerk enterprise connection configured against your identity provider (SAML/OIDC). Connection setup lives in the Clerk dashboard and is **TBD** in this documentation.
    * `CLERK_JWKS_URL` and `CLERK_ISSUER` configured on the backend (managed for you on cloud).
    * Users are provisioned JIT on first sign-in as organization `owner` of a default org; live roles are `owner`/`admin`.
  </Accordion>

  <Accordion title="To self-host" icon="server">
    * An Enterprise plan with `feature.self_host = true`.
    * `ENVIRONMENT=production` (or `staging`).
    * `ENCRYPTION_KEY` and `API_KEY_PEPPER`, each at least 32 characters — both production-required. Set `API_KEY_PEPPER` explicitly when you self-host.
    * A PostgreSQL `DATABASE_URL` (async driver) and a `REDIS_URL`.
    * `CLERK_JWKS_URL` and `CLERK_ISSUER` for sign-in.
    * `ADMIN_DASHBOARD_API_KEYS` if you use the platform admin dashboard (the gate fails closed when empty).
    * Optionally, `AZURE_KEY_VAULT_URL` to source secrets from Azure Key Vault.
    * The startup security gate exits the process with code `1` if any required secret is missing or too short in `production`/`staging`.
    * Container images, orchestration, and runtime topology are **TBD** — confirm with the ModuleX team.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Authentication model" icon="lock" href="/security/authentication">
    Clerk JWT verification, `mx_live_*` API keys, and the header forms every caller uses.
  </Card>

  <Card title="Data security & encryption" icon="shield" href="/security/data-encryption">
    How credentials and secrets are encrypted, and the production security checks.
  </Card>

  <Card title="Roles & permissions" icon="users" href="/security/roles-permissions">
    The live `owner`/`admin` role model and what each can do.
  </Card>

  <Card title="Contact sales" icon="envelope" href="/enterprise/contact-sales">
    Enable SSO and self-hosting on an Enterprise plan.
  </Card>
</CardGroup>
