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

# Custom MCP Servers

> Deploy custom MCP servers to managed infrastructure

Runlayer Deploy enables you to deploy custom MCP servers to managed infrastructure with automatic scaling, monitoring, and HTTPS endpoints.

<Note>
  Want to build and deploy your MCP server automatically with your agent? Start with the [MCP Builder Quickstart](/mcp-custom-servers). That flow uses Runlayer MCP + MCP Builder on top of Runlayer Deploy.
</Note>

**When to use Runlayer Deploy:**

* Deploy custom MCP servers not already available as connectors
* Build internal or proprietary integrations
* Run services requiring specific dependencies or configurations

***

## Prerequisites

Before deploying, ensure you have:

* Docker installed and running locally
* Runlayer CLI installed (`uvx runlayer`)
* A Dockerfile and application code ready to deploy

<Note>
  Non-admin users can **request** deploy-based MCP servers through the connectors flow. The request follows the standard approval workflow. Direct deployment via the CLI requires admin permissions.
</Note>

***

## Quick Start

<Steps>
  <Step title="Log in to Runlayer">
    Authenticate the CLI with your Runlayer instance:

    ```bash theme={null}
    uvx runlayer login --host <runlayer-url>
    ```
  </Step>

  <Step title="Initialize Deployment">
    Create a new deployment and generate configuration:

    ```bash theme={null}
    uvx runlayer deploy init --host <runlayer-url>
    ```

    This creates a deployment in the backend and generates a `runlayer.yaml` template with your deployment ID. You can also pick a custom icon for the deployment during creation or update it later from the deployment settings dialog.
  </Step>

  <Step title="Configure Service">
    Edit the generated `runlayer.yaml` file:

    * Set your service port
    * Choose CPU and memory resources
    * Add environment variables
    * Configure build settings for your Dockerfile
  </Step>

  <Step title="Deploy">
    Build and deploy your service:

    ```bash theme={null}
    uvx runlayer deploy --config runlayer.yaml --host <runlayer-url>
    ```

    To change configuration later, see [Updating a deployment](#updating-a-deployment).
  </Step>
</Steps>

***

## Runtimes

### Docker

The Docker runtime builds your application locally using Docker and deploys it as a container. This gives you full control over dependencies and supports any language or framework.

**Build Configuration:**

* `dockerfile`: Path to your Dockerfile (default: `Dockerfile`)
* `context`: Build context directory (default: `.`)
* `platform`: CPU architecture - `x86` or `arm` (default: `arm`, recommended for cost savings)
* `args`: Build arguments as key-value pairs
* `target`: Target stage for multi-stage builds (optional)

### Coming Soon

<Note>
  **Managed Runtimes** for Python and TypeScript—deploy with just your code and dependencies.
</Note>

***

## Configuration Reference

Complete `runlayer.yaml` structure:

```yaml theme={null}
id: <uuid>              # Auto-generated, do not modify
name: my-service        # Lowercase, URL-friendly name
runtime: docker         # Currently only "docker" is supported

build:
  dockerfile: Dockerfile
  context: .
  platform: arm         # "arm" or "x86" (arm recommended)
  target: null          # Optional: multi-stage build target
  args: {}              # Build arguments (key-value pairs)

service:
  port: 8000           # Container port your app listens on
  path: /mcp           # URL path prefix (optional)
  expose: []           # OAuth-only unauthenticated paths (optional)

infrastructure:
  cpu: 512             # CPU units
  memory: 1024         # Memory in MB
  platform: arm        # CPU architecture (must match build.platform)
  enable_db: false     # Enable database storage
  aws:                 # AWS config (hosted deployments only)
    assume_roles: {}   # Named map of IAM role ARNs to assume

env: {}                # Environment variables (key-value pairs)
```

### Field Details

**`service.expose`**

Allow unauthenticated access to OAuth routes your deployed MCP server implements. Omit `service.expose` for non-OAuth servers.

Use this only for OAuth discovery and OAuth protocol endpoints that must be reachable before a client has a Runlayer Bearer token:

```yaml theme={null}
service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/register"
    - "/authorize"
    - "/token"
    - "/revoke"
```

<Warning>
  Exposed paths bypass Runlayer authentication. Do not use Deploy to publish arbitrary unauthenticated HTTP routes, APIs, webhooks, or probes. MCP endpoints (`service.path`, `/mcp`, and legacy `/sse`) must stay authenticated and should not be listed in `service.expose`.
</Warning>

**`infrastructure`**

Valid CPU and memory combinations:

| CPU (vCPU)     | Memory Options (MB)                      | Best For          |
| -------------- | ---------------------------------------- | ----------------- |
| 256 (.25 vCPU) | 512, 1024, 2048                          | Minimal services  |
| 512 (.5 vCPU)  | 1024, 2048, 3072, 4096                   | Light services    |
| 1024 (1 vCPU)  | 2048, 3072, 4096, 5120, 6144, 7168, 8192 | Standard services |
| 2048 (2 vCPU)  | 4096-16384 (1024 MB increments)          | Medium workloads  |
| 4096 (4 vCPU)  | 8192-30720 (1024 MB increments)          | Heavy workloads   |

<Note>
  See [AWS Fargate pricing](https://aws.amazon.com/fargate/pricing/) for cost estimates.
</Note>

**`infrastructure.platform`**

CPU architecture for the container runtime: `x86` or `arm`.

* In **build mode**: Defaults to `build.platform`. If explicitly set, must match `build.platform` or validation will fail.
* In **image mode**: Defaults to `x86` if not specified. Set this to match the architecture of your pre-built Docker image.

**`infrastructure.enable_db`**

Enable a managed database for persistent storage. When enabled, a DynamoDB table is provisioned with the following environment variables injected into your container:

* `DB_TABLE_NAME` - The DynamoDB table name
* `DB_TABLE_REGION` - The AWS region of the table

The table uses a partition key (`pk`) and sort key (`sk`) schema. TTL is enabled via the `ttl` attribute (set to a Unix timestamp to auto-expire items).

Use any standard DynamoDB client to interact with the table—boto3 (Python), AWS SDK (Node.js), or any AWS SDK. All standard DynamoDB operations are supported: `GetItem`, `PutItem`, `Query`, `Scan`, `BatchGetItem`, `BatchWriteItem`, etc.

**Example MCP tool with persistence (Python):**

```python theme={null}
import os
import boto3
from typing import Any

dynamodb = boto3.resource("dynamodb", region_name=os.environ.get("DB_TABLE_REGION"))

@mcp.tool
def add_item(pk: str, sk: str, data: dict[str, Any]) -> dict:
    """Add an item to the database."""
    table_name = os.environ.get("DB_TABLE_NAME")
    if not table_name:
        return {"error": "DB_TABLE_NAME not set"}

    table = dynamodb.Table(table_name)
    item = {"pk": pk, "sk": sk, **data}

    try:
        table.put_item(Item=item)
        return {"success": True, "item": item}
    except Exception as e:
        return {"success": False, "error": str(e)}
```

<Warning>
  **Irreversible:** Once `enable_db` is set to `true` and deployed, it **cannot be disabled**. Attempting to set it back to `false` will fail validation to prevent data loss.
</Warning>

<Tip>
  Building a deployed server that needs OAuth with a third-party service (e.g., Slack, Google)? See the [OAuth for Deployed MCP Servers](/deploy-oauth) guide for the dual-auth architecture and token storage patterns.
</Tip>

<h3 id="aws-cross-account-roles-hosted-only">
  AWS cross-account roles (hosted only)
</h3>

Use `infrastructure.aws.assume_roles` when a **hosted** connector needs to call AWS APIs in **your** account without storing long-lived access keys in `env`. This applies only to Runlayer-hosted ECS deployments—not remote or local catalog connectors.

**How it works**

1. You declare customer role ARNs in `infrastructure.aws.assume_roles`.
2. Runlayer provisions a **dedicated ECS task role per deployment** in the Runlayer-hosted AWS account.
3. The connector container runs with that task role and calls `sts:AssumeRole` on your roles using `ExternalId = <deployment-id>`.
4. Runlayer does not broker STS or store static AWS keys.

**Runlayer-side config**

```yaml theme={null}
infrastructure:
  aws:
    assume_roles:
      secrets: arn:aws:iam::123456789012:role/acme-mcp-secrets-reader
      blobs: arn:aws:iam::123456789012:role/acme-mcp-s3-reader
```

**Runtime environment variables**

| Variable                            | Description                                                                                       |
| ----------------------------------- | ------------------------------------------------------------------------------------------------- |
| `RUNLAYER_DEPLOYMENT_ID`            | Deployment UUID — pass as `ExternalId` when assuming customer roles                               |
| `RUNLAYER_DEPLOYMENT_TASK_ROLE_ARN` | This deployment's ECS task role — the principal your trust policy must allow                      |
| `RUNLAYER_AWS_ROLE_<NAME>`          | Customer role ARN for map key `<NAME>` (uppercased, e.g. `secrets` → `RUNLAYER_AWS_ROLE_SECRETS`) |

**Customer setup checklist (per role ARN)**

| Step | Action                                                                                                                 |
| ---- | ---------------------------------------------------------------------------------------------------------------------- |
| 1    | Create or choose a least-privilege IAM role in your AWS account                                                        |
| 2    | Attach a **permissions policy** granting only what the connector needs (S3, Secrets Manager, etc.)                     |
| 3    | Add a **trust policy** allowing **only** the Runlayer deployment task role ARN with `sts:ExternalId` = deployment UUID |
| 4    | Add the role ARN to `infrastructure.aws.assume_roles.<name>` in `runlayer.yaml`                                        |
| 5    | Deploy and copy `task_role_arn` from CLI output, deployment page, or API                                               |

**Where to find the assuming principal**

| Source                           | Field                               |
| -------------------------------- | ----------------------------------- |
| `runlayer deploy` success output | `task_role_arn`                     |
| Deployment API / dashboard       | `task_role_arn`                     |
| Running container                | `RUNLAYER_DEPLOYMENT_TASK_ROLE_ARN` |

`task_role_arn` is **per deployment** and stable across redeploys. Use it as the sole `Principal.AWS` in your trust policy—not the Runlayer AWS account ID or a broad shared role.

**Trust policy template**

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowRunlayerDeploymentTaskRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "<RUNLAYER_DEPLOYMENT_TASK_ROLE_ARN>"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "<deployment-uuid>"
        }
      }
    }
  ]
}
```

* `<RUNLAYER_DEPLOYMENT_TASK_ROLE_ARN>` — from deploy output / API / `RUNLAYER_DEPLOYMENT_TASK_ROLE_ARN` env var
* `<deployment-uuid>` — the `id` in `runlayer.yaml` (same as `RUNLAYER_DEPLOYMENT_ID`)

**Trust vs permissions**

* **Trust policy** — who can assume the role (the Runlayer deployment task role only)
* **Permissions policy** — what the connector can do after assume (your data-plane access)

**Troubleshooting**

Deploy succeeds once Runlayer infrastructure is provisioned; runtime `AssumeRole` errors are authoritative if your customer trust policy is not ready yet. Common issues: wrong principal (account root instead of `task_role_arn`), missing or incorrect `ExternalId`, typo in role ARN.

<Warning>
  Scope trust policies to the deployment's `task_role_arn`. Do not grant broad Runlayer account access. Use least-privilege permissions policies on customer roles.
</Warning>

**`env`**

Environment variables are passed to your container at runtime. Values are automatically masked as `***` in stored configurations and API responses for security.

**CLI Variable Substitution:**

The CLI supports shell-style environment variable substitution when loading `runlayer.yaml`, allowing you to reference local environment variables or `.env` files without hardcoding sensitive values.

**Syntax:**

* `${VAR}` - Required variable (deployment fails if not set)
* `${VAR:-default}` - Use default value if variable is unset or empty
* `${VAR-default}` - Use default value only if variable is unset (not if empty)
* `$$DEPLOYMENT_URL` - **Special backend variable** - your deployment's public URL (backend replaces at runtime)
* `$$RUNLAYER_URL` - **Special backend variable** - the Runlayer platform URL
* `$$RUNLAYER_OAUTH_CALLBACK_URL` - **Special backend variable** - OAuth callback URL for integrations

<Note>
  **`$$DEPLOYMENT_URL`** is a special variable that resolves to your deployment's URL. OAuth servers commonly use it as their issuer/base URL.
</Note>

**Example with CLI substitution:**

```yaml theme={null}
env:
  DATABASE_URL: ${DATABASE_URL}                # Required from environment
  API_KEY: ${API_KEY}                          # Required from environment
  LOG_LEVEL: ${LOG_LEVEL:-info}                # Defaults to 'info'
  DEBUG: ${DEBUG:-false}                       # Defaults to 'false'
  CALLBACK_URL: $$DEPLOYMENT_URL/callback   # Backend replaces with deployment URL
```

**Loading variables:**

```bash theme={null}
# From environment
export DATABASE_URL=postgres://localhost/db
export API_KEY=secret123
uvx runlayer deploy --host <runlayer-url>

# From .env file (auto-discovered)
# Place .env next to runlayer.yaml or in current directory
uvx runlayer deploy --host <runlayer-url>

# From specific .env file
uvx runlayer deploy --host <runlayer-url> --env-file .env.prod
```

**Auto-discovery:** The CLI automatically searches for `.env` in your current working directory.

**Automatic Environment Variables:**

The backend automatically injects the following variables into your container:

* `DEPLOYMENT_URL` - Your deployment URL: `https://your-platform.com/api/v1/proxy/hosted/{deployment_id}`
* `RUNLAYER_URL` - The Runlayer platform URL
* `RUNLAYER_OAUTH_CALLBACK_URL` - OAuth callback URL: `https://your-platform.com/oauth/callback`

This allows OAuth-aware services to know their deployment URL and build issuer or callback metadata.

**Backend Placeholder Replacement:**

Use `$$DEPLOYMENT_URL` (double dollar sign) as a placeholder in your YAML configuration. The backend replaces this at deployment time with your deployment URL.

**Example:**

```yaml theme={null}
service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/authorize"
    - "/token"

env:
  # Backend replaces $$DEPLOYMENT_URL with actual deployment URL
  OAUTH_ISSUER: "$$DEPLOYMENT_URL"
```

**At runtime, this becomes:**

* `OAUTH_ISSUER`: `https://your-platform.com/api/v1/proxy/hosted/{deployment_id}`

<Warning>
  **Important:** Use double `$$` (`$$DEPLOYMENT_URL`), not single `$`.
</Warning>

***

## CLI Commands

### CI/CD with organization API keys

For automated deploy pipelines, create an organization API key with the **Deploy** role in **Settings → Organization API keys**. Deploy-scoped organization keys can initialize, validate, deploy, pull, and destroy deployments without being tied to a user session.

You can pass the key directly:

```bash theme={null}
uvx runlayer deploy --config runlayer.yaml --host <runlayer-url> --secret <org-api-key>
```

Or store it locally under a name and reference it in deploy commands:

```bash theme={null}
uvx runlayer org-api-key add ci-deploy --host <runlayer-url>
uvx runlayer --org-api-key ci-deploy deploy --config runlayer.yaml --host <runlayer-url>
```

<Note>
  Deploy organization keys are intended for CI/CD deployment workflows. They can manage deployment records and infrastructure, including `deploy destroy`, but they cannot register deployed services as MCP connectors during deployment.
</Note>

***

### `deploy init`

Initialize a new deployment and generate configuration file.

```bash theme={null}
uvx runlayer login --host <runlayer-url>
uvx runlayer deploy init --host <runlayer-url>
```

**What it does:**

* Creates a deployment record in your Runlayer instance
* Generates a `runlayer.yaml` template with your deployment ID
* Prompts for deployment name (must be lowercase and URL-friendly)

**Options:**

* `--config`, `-c`: Path to create config file (default: `runlayer.yaml`)
* `--secret`, `-s`: API key override (optional; prefer `uvx runlayer login`)
* `--host`, `-H`: Runlayer instance URL (required)

***

### `deploy`

Build and deploy your service.

```bash theme={null}
uvx runlayer deploy --config runlayer.yaml --host <runlayer-url>
```

**What it does:**

1. Loads environment variables from `.env` (auto-discovered) or `--env-file`
2. Substitutes variables in your YAML configuration
3. Validates your YAML configuration
4. Builds Docker image locally using your Dockerfile
5. Pushes image to managed registry
6. Deploys infrastructure
7. Monitors deployment with live log streaming
8. Prints the deployment URL so you can open it directly

**Options:**

* `--config`, `-c`: Path to config file (default: `runlayer.yaml`)
* `--secret`, `-s`: API key override (optional; prefer `uvx runlayer login`)
* `--host`, `-H`: Runlayer instance URL (required)
* `--env-file`, `-e`: Path to .env file for variable substitution (optional, auto-discovers `.env` by default)

***

### `deploy validate`

Validate configuration without deploying.

```bash theme={null}
uvx runlayer deploy validate --config runlayer.yaml --host <runlayer-url>
```

Checks YAML syntax and validates against backend schema. Useful for CI/CD pipelines or pre-deployment validation.

**Options:**

* `--config`, `-c`: Path to config file (default: `runlayer.yaml`)
* `--secret`, `-s`: API key override (optional; prefer `uvx runlayer login`)
* `--host`, `-H`: Runlayer instance URL (required)
* `--env-file`, `-e`: Path to .env file for variable substitution (optional, auto-discovers `.env` by default)

***

### `deploy pull`

Fetch the current deployment configuration from the backend and save it as a local `runlayer.yaml`.

```bash theme={null}
uvx runlayer deploy pull --host <runlayer-url>
```

**What it does:**

1. Reads the deployment ID from the existing `runlayer.yaml` (or `--deployment-id`)
2. Creates a timestamped backup of the current file (if present)
3. Downloads the latest configuration and writes it locally

Environment variables appear as `${VAR_NAME}` placeholders; backend variables like `$$DEPLOYMENT_URL` are preserved as-is.

**Options:**

* `--config`, `-c`: Path to save config file (default: `runlayer.yaml`)
* `--deployment-id`, `-d`: Deployment ID (overrides config file)
* `--secret`, `-s`: API key override (optional; prefer `uvx runlayer login`)
* `--host`, `-H`: Runlayer instance URL (required)

***

### Updating a deployment

Configuration changes are made through the CLI, not the web UI:

1. Get the current configuration — use your local `runlayer.yaml`, or fetch the latest with `deploy pull` (see above)
2. Edit the YAML — port, CPU/memory, environment variables, build settings, exposed paths, etc.
3. Redeploy with `uvx runlayer deploy --config runlayer.yaml --host <runlayer-url>`

The UI supports a smaller set of post-deployment actions:

| Action                                                                        | Where                                             |
| ----------------------------------------------------------------------------- | ------------------------------------------------- |
| Rename deployment, change icon                                                | Deployment settings dialog                        |
| Redeploy the current configuration (e.g. after fixing an external dependency) | **Redeploy** action on the deployment detail page |
| Delete deployment                                                             | Deployment detail page                            |

<Note>
  The deployment settings dialog only edits the display name and icon. All `runlayer.yaml` fields (service, build, infrastructure, env) require the CLI edit-and-redeploy workflow above.
</Note>

***

### `deploy destroy`

Teardown deployment and destroy infrastructure.

```bash theme={null}
uvx runlayer deploy destroy --config runlayer.yaml --host <runlayer-url>
```

<Warning>
  This action is **destructive and cannot be undone**. All infrastructure resources will be permanently deleted.
</Warning>

If the deployment has connected MCP servers, the UI will show them and offer a **Delete All** option that removes the deployment and its connected servers in one step (requires permission to delete those servers). Without permission, you must ask the connector owner to disconnect them first.

**Options:**

* `--config`, `-c`: Path to config file containing deployment ID
* `--deployment-id`, `-d`: Deployment ID (overrides config file)
* `--secret`, `-s`: API key override (optional; prefer `uvx runlayer login`)
* `--host`, `-H`: Runlayer instance URL (required)

***

## Examples

### Example: Multi-stage Node.js Build with legacy SSE transport

TypeScript MCP server with build arguments and optimized production build:

```yaml theme={null}
name: custom-mcp
runtime: docker

build:
  dockerfile: Dockerfile
  context: .
  platform: x86
  target: production
  args:
    NODE_VERSION: "20"

service:
  port: 3000
  path: /sse

infrastructure:
  cpu: 1024
  memory: 2048

env:
  NODE_ENV: "production"
```

***

### Example: Environment Variable Substitution

Production MCP server with secrets loaded from `.env` file:

**runlayer.yaml:**

```yaml theme={null}
name: production-mcp
runtime: docker

build:
  dockerfile: Dockerfile
  context: .
  platform: arm

service:
  port: 8000
  path: /mcp

infrastructure:
  cpu: 1024
  memory: 2048

env:
  # Required variables (CLI substitutes from .env file)
  DATABASE_URL: ${DATABASE_URL}
  API_KEY: ${API_KEY}
  JWT_SECRET: ${JWT_SECRET}

  # Variables with defaults (CLI substitutes with fallback)
  LOG_LEVEL: ${LOG_LEVEL:-info}
  DEBUG: ${DEBUG:-false}
  WORKERS: ${WORKERS:-4}

  # Special backend variable
  BASE_URL: $$DEPLOYMENT_URL
```

**.env:**

```bash theme={null}
DATABASE_URL=postgres://prod-db.example.com:5432/myapp
API_KEY=prod_key_12345
JWT_SECRET=super_secret_jwt_key
LOG_LEVEL=warn
WORKERS=8
```

**Deploy:**

```bash theme={null}
# Uses .env automatically (same directory as runlayer.yaml)
uvx runlayer deploy --host <runlayer-url>

# Or specify explicitly
uvx runlayer deploy --host <runlayer-url> --env-file .env.production
```

<Note>
  **Timing matters:** CLI substitution (`${VAR}`) happens **before** sending to backend. Backend substitution (`$$DEPLOYMENT_URL`) happens **at deployment time**. This means validation errors will show CLI-substituted values, but `$$DEPLOYMENT_URL` remains as-is until the backend deploys your service.
</Note>

***

### Example: OAuth MCP Server

OAuth-enabled MCP server with unauthenticated OAuth routes:

```yaml theme={null}
name: oauth-mcp
runtime: docker

build:
  dockerfile: Dockerfile
  context: .
  platform: arm

service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/register"
    - "/authorize"
    - "/token"
    - "/revoke"

infrastructure:
  cpu: 512
  memory: 1024

env:
  OAUTH_CLIENT_ID: "${OAUTH_CLIENT_ID}"
  OAUTH_CLIENT_SECRET: "${OAUTH_CLIENT_SECRET}"
  OAUTH_REDIRECT_URIS: "$$RUNLAYER_OAUTH_CALLBACK_URL"
  BASE_URL: "$$DEPLOYMENT_URL"
```

***

## Registering as an MCP Server

After your deployment succeeds, register it as an MCP server to make it available in Connectors:

1. Navigate to the deployment detail page in the Runlayer UI
2. Click **"Register as MCP Server"** and update any information you need
3. Click on "Create MCP"
4. Your deployed service is now available as a connector in Runlayer

Connected MCP servers appear in the **Connectors** tab on the deployment detail page with per-server tool counts.

***

## Identity Forward

When enabled on any HTTP-based MCP connector (Streaming HTTP or SSE), the Runlayer proxy injects the authenticated caller's identity into upstream HTTP requests. Your server reads it to scope data per user, write meaningful audit logs, enforce per-user rate limits, or personalize responses. Identity Forward works on catalog connectors, manually added connectors, and deployed connectors alike — it is not limited to Runlayer Deploy.

There are two independent toggles:

| Toggle                                          | Carries                                                                                | When to use                                                                      |
| ----------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Forward identity headers**                    | Plain `X-Runlayer-*` headers. Read them directly — no SDK, no verification.            | Upstreams you fully control on a trusted network path.                           |
| **Forward signed identity token** *(preferred)* | A short-lived Runlayer-signed JWT in `X-Runlayer-Identity-Token`, verifiable via JWKS. | Any upstream you want to cryptographically prove the call came through Runlayer. |

The toggles are independent and may be enabled together during a migration. Prefer the signed token whenever the path between Runlayer and the upstream isn't fully trusted.

<Warning>
  * Plain headers are network-trust only. Anything that can reach the upstream URL directly could spoof them. The **signed token** option closes that gap — see *Verifying the signed token* below.
  * Identity is NOT a substitute for authorization. Enforce permissions in Runlayer policies and your server logic.
  * Runlayer reserves the identity header names (`X-Runlayer-Subject-Type`, `X-Runlayer-Org-Id`, `X-Runlayer-User-Email`, `X-Runlayer-User-Id`, `X-Runlayer-Agent-Id`, `X-Runlayer-Agent-Name`, `X-Runlayer-Identity-Token`). They are stripped from any admin-configured `headers` or placeholder substitution before the request is sent, then re-injected by the proxy when the corresponding toggle is on. Other `X-Runlayer-*` headers (e.g. `X-Runlayer-Custom-Foo`) are admin-defined and pass through untouched.
  * Header / claim values are never written to Runlayer proxy request logs (only a boolean `identity_forward_applied`, the subject type, and which mode was applied are logged).
  * Both toggles are off by default and admin-only. Enabling sends end-user emails / agent identifiers to the upstream server — accept responsibility per your privacy policy.
</Warning>

### Headers sent

| Header                    | Description                   | Sent for       |
| ------------------------- | ----------------------------- | -------------- |
| `X-Runlayer-Subject-Type` | One of `user`, `agent`, `obo` | always         |
| `X-Runlayer-Org-Id`       | Runlayer organization UUID    | always         |
| `X-Runlayer-User-Email`   | End-user email                | `user`, `obo`  |
| `X-Runlayer-User-Id`      | Runlayer user UUID            | `user`, `obo`  |
| `X-Runlayer-Agent-Id`     | Agent account UUID            | `agent`, `obo` |
| `X-Runlayer-Agent-Name`   | Agent display name            | `agent`, `obo` |

### Subject types

| Subject type | When it occurs                                                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `user`       | A human end-user calling directly through their MCP client (Cursor, Claude Desktop, etc.).                                |
| `agent`      | A standalone agent account (machine-to-machine) calling with its own credentials. No human in the loop.                   |
| `obo`        | An agent calling **on behalf of** a delegating user. Both agent identity and the delegator's user identity are forwarded. |

### Example requests

Human user:

```http theme={null}
POST /mcp HTTP/1.1
Host: my-server.deploy.runlayer.com
X-Runlayer-Subject-Type: user
X-Runlayer-Org-Id: 9f3a...
X-Runlayer-User-Email: jane@acme.com
X-Runlayer-User-Id: 7b1c...
```

Standalone agent (m2m):

```http theme={null}
POST /mcp HTTP/1.1
Host: my-server.deploy.runlayer.com
X-Runlayer-Subject-Type: agent
X-Runlayer-Org-Id: 9f3a...
X-Runlayer-Agent-Id: a4d2...
X-Runlayer-Agent-Name: nightly-reporter
```

Agent on-behalf-of a user (OBO):

```http theme={null}
POST /mcp HTTP/1.1
Host: my-server.deploy.runlayer.com
X-Runlayer-Subject-Type: obo
X-Runlayer-Org-Id: 9f3a...
X-Runlayer-User-Email: jane@acme.com
X-Runlayer-User-Id: 7b1c...
X-Runlayer-Agent-Id: a4d2...
X-Runlayer-Agent-Name: jane-research-agent
```

### Signed identity token

When **Forward signed identity token** is enabled, Runlayer mints a short-lived JWT for every upstream request and sends it as:

```http theme={null}
X-Runlayer-Identity-Token: <jwt>
```

Verify it with any standard JWT library against the JWKS Runlayer publishes:

```
GET https://<your-runlayer>/.well-known/runlayer-identity-forward.jwks.json
```

**Token shape:**

| Claim                    | Value                                                                                                                                                                                                              |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `alg` (header)           | `EdDSA`                                                                                                                                                                                                            |
| `kid` (header)           | Stable fingerprint of the signing key.                                                                                                                                                                             |
| `iss`                    | Runlayer instance URL.                                                                                                                                                                                             |
| `aud`                    | `runlayer:identity-forward:<server-id>` — per-upstream so a token minted for one server can't be replayed against another. The `<server-id>` is the Runlayer server UUID surfaced in the admin UI for that server. |
| `iat` / `exp`            | Issued-at + expiry; TTL is 5 minutes.                                                                                                                                                                              |
| `jti`                    | Unique per request.                                                                                                                                                                                                |
| `sub`                    | `user_id` when present, otherwise `agent_id`.                                                                                                                                                                      |
| `subject_type`           | `user`, `agent`, or `obo`.                                                                                                                                                                                         |
| `organization_id`        | Runlayer organization UUID.                                                                                                                                                                                        |
| `user_email`, `user_id`  | Present for `user` and `obo` subjects.                                                                                                                                                                             |
| `agent_id`, `agent_name` | Present for `agent` and `obo` subjects.                                                                                                                                                                            |

#### Verifying the signed token

Python (FastAPI + PyJWT):

```python theme={null}
import functools
import jwt
from fastapi import HTTPException, Request

RUNLAYER = "https://<your-runlayer>"
# Each upstream pins its own server-scoped audience. Copy the Runlayer
# server UUID from the server detail page in the admin UI.
RUNLAYER_SERVER_ID = "<runlayer-server-uuid>"
AUDIENCE = f"runlayer:identity-forward:{RUNLAYER_SERVER_ID}"


@functools.lru_cache(maxsize=1)
def _jwks_client() -> jwt.PyJWKClient:
    return jwt.PyJWKClient(
        f"{RUNLAYER}/.well-known/runlayer-identity-forward.jwks.json"
    )


def verified_caller(req: Request) -> dict:
    token = req.headers.get("x-runlayer-identity-token")
    if not token:
        raise HTTPException(401, "missing identity token")
    signing_key = _jwks_client().get_signing_key_from_jwt(token).key
    try:
        return jwt.decode(
            token,
            signing_key,
            algorithms=["EdDSA"],
            audience=AUDIENCE,
            issuer=RUNLAYER,
        )
    except jwt.InvalidTokenError as e:
        raise HTTPException(401, f"invalid identity token: {e}")
```

TypeScript (Express + `jose`):

```typescript theme={null}
import { createRemoteJWKSet, jwtVerify } from "jose";

const RUNLAYER = "https://<your-runlayer>";
// Per-upstream audience: copy the Runlayer server UUID from the admin UI.
const RUNLAYER_SERVER_ID = "<runlayer-server-uuid>";
const AUDIENCE = `runlayer:identity-forward:${RUNLAYER_SERVER_ID}`;
const JWKS = createRemoteJWKSet(
  new URL(`${RUNLAYER}/.well-known/runlayer-identity-forward.jwks.json`),
);

export async function verifiedCaller(req: Request) {
  const token = req.header("x-runlayer-identity-token");
  if (!token) throw new Error("missing identity token");
  const { payload } = await jwtVerify(token, JWKS, {
    audience: AUDIENCE,
    issuer: RUNLAYER,
  });
  return payload;
}
```

### Enable

1. Open the server's configuration in Runlayer (admin only).
2. Toggle **Forward identity headers** and/or **Forward signed identity token** on. The signed option is recommended; the plain headers are kept for backwards compatibility and trusted-network scenarios.
3. Save. The standard server-update audit log entry records the change like any other server-config edit.

### Reading the headers

Python (FastAPI / FastMCP):

```python theme={null}
from fastapi import Request, HTTPException


def get_caller(req: Request) -> dict:
    subject_type = req.headers.get("x-runlayer-subject-type")
    if subject_type is None:
        raise HTTPException(401, "missing identity")

    return {
        "subject_type": subject_type,
        "org_id": req.headers.get("x-runlayer-org-id"),
        "user_email": req.headers.get("x-runlayer-user-email"),
        "user_id": req.headers.get("x-runlayer-user-id"),
        "agent_id": req.headers.get("x-runlayer-agent-id"),
        "agent_name": req.headers.get("x-runlayer-agent-name"),
    }
```

TypeScript (Express):

```typescript theme={null}
function getCaller(req: Request) {
  const subjectType = req.header("x-runlayer-subject-type");
  if (!subjectType) throw new Error("missing identity");

  return {
    subjectType,
    orgId: req.header("x-runlayer-org-id"),
    userEmail: req.header("x-runlayer-user-email"),
    userId: req.header("x-runlayer-user-id"),
    agentId: req.header("x-runlayer-agent-id"),
    agentName: req.header("x-runlayer-agent-name"),
  };
}
```

### Limitations

* Transports: `streaming-http` and `sse` only. Not supported on `stdio` (use environment variables / placeholders if you need identity for stdio).
* Email-only personalization. Organization roles, groups, and custom claims are not forwarded.
* Runlayer self-MCP (`runlayer-mcp`) does not receive identity headers or tokens; it already runs in-process with full subject context.
* Signed tokens have a 5-minute TTL. Verify upstream **and** re-fetch the JWKS when an unknown `kid` is presented (rotation rotates `kid` automatically).

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Docker build fails">
    **Solutions:**

    * Test build locally first: `docker build -t test .`
    * Check Dockerfile syntax and instructions
    * Verify build context includes all required files
    * Check build arguments are passed correctly
  </Accordion>

  <Accordion title="Invalid CPU/memory combination">
    **Solution:**

    Refer to the configuration reference table for valid combinations. Not all CPU/memory pairs are supported. For example, 256 CPU only supports 512, 1024, or 2048 MB memory.
  </Accordion>

  <Accordion title="Deployment stuck or timeout">
    **Solutions:**

    Check deployment logs in the web UI. Common issues:

    * Container failing health checks (check your app responds on the configured port)
    * Wrong port configured in YAML
    * Missing required environment variables
    * Application crashes at startup (check application logs)
  </Accordion>

  <Accordion title="Can't destroy deployment">
    **Solution:**

    If the deployment has connected MCP servers, use the **Delete All** option to remove the deployment and its servers together. If you lack permission to delete those servers, ask the connector owner or an administrator to do it.
  </Accordion>

  <Accordion title="Environment variables not working">
    **Explanation:**

    Values are automatically masked as `***` in stored configurations and API responses for security. This is expected behavior. Your actual values are securely passed to the container at runtime and are not visible in the UI or API.
  </Accordion>

  <Accordion title="Variable substitution errors">
    **Common issues:**

    * **"Required environment variable 'VAR' is not set"**: Variable isn't in your environment or `.env` file. Check spelling and ensure it's exported.
    * **Wrong default syntax**: Use `${VAR:-default}` (with colon) to handle empty strings, or `${VAR-default}` (no colon) to only use default if unset.
    * **`$$DEPLOYMENT_URL` getting substituted by CLI**: Use **double dollar sign** (`$$DEPLOYMENT_URL`), not single (`${DEPLOYMENT_URL}`). Single dollar triggers CLI substitution, double dollar preserves it for backend replacement.
  </Accordion>

  <Accordion title="Build works locally but fails in deployment">
    **Solutions:**

    * Verify platform architecture matches (`x86` vs `arm`)
    * Check all dependencies are in Dockerfile, not just on your local machine
    * Ensure build arguments are specified in YAML if required
    * Review build logs for missing packages or permission errors
  </Accordion>

  <Accordion title="Upstream server doesn't see identity headers / token">
    **Checklist:**

    * The relevant **Identity Forward** toggle (headers and/or signed token) is on for this server (admin server config).
    * Transport is `streaming-http` or `sse`. `stdio` is not supported.
    * Caller has identity (logged-in user, OBO agent, or standalone agent account); anonymous calls forward only `Subject-Type` and `Org-Id`.
    * You're reading the headers case-insensitively (HTTP header names are not case-sensitive; httpx canonicalizes them on send).
    * For the signed token: the verifier's expected audience must be `runlayer:identity-forward:<server-id>` for **this specific server** (copy the UUID from the Runlayer admin UI). A token minted for server A intentionally fails audience check on server B. Also confirm the JWKS URL is reachable from the upstream environment.
  </Accordion>

  <Accordion title="exec format error">
    ```
    exec /usr/local/bin/python: exec format error
    ```

    **Cause:** The Docker image architecture doesn't match `infrastructure.platform`.

    **Fix:** Set `infrastructure.platform` to match your docker image architecture.

    * For x86/amd64 images: `platform: x86`
    * For ARM images: `platform: arm`
  </Accordion>
</AccordionGroup>

***

## Next Steps

<CardGroup cols={2}>
  <Card title="MCP Builder Quickstart" icon="wand-magic-sparkles" href="/mcp-custom-servers">
    Scaffold a custom MCP server fast with the MCP Builder prompt
  </Card>

  <Card title="OAuth for Deployed Servers" icon="lock" href="/deploy-oauth">
    Dual-auth architecture and token storage for OAuth connectors
  </Card>

  <Card title="Connectors" icon="server" href="/platform-connectors">
    View and manage your connectors
  </Card>

  <Card title="Security Best Practices" icon="shield" href="/mcp-security-best-practices">
    Secure your MCP deployments and integrations
  </Card>
</CardGroup>
