Skip to main content
Runlayer Deploy enables you to deploy custom MCP servers to managed infrastructure with automatic scaling, monitoring, and HTTPS endpoints.
Want to build and deploy your MCP server automatically with your agent? Start with the MCP Builder Quickstart. That flow uses Runlayer MCP + MCP Builder on top of Runlayer Deploy.
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
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.

Quick Start

1

Log in to Runlayer

Authenticate the CLI with your Runlayer instance:
uvx runlayer login --host <runlayer-url>
2

Initialize Deployment

Create a new deployment and generate configuration:
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.
3

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
4

Deploy

Build and deploy your service:
uvx runlayer deploy --config runlayer.yaml --host <runlayer-url>

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

Managed Runtimes for Python and TypeScript—deploy with just your code and dependencies.

Configuration Reference

Complete runlayer.yaml structure:
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:
service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/register"
    - "/authorize"
    - "/token"
    - "/revoke"
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.
infrastructure Valid CPU and memory combinations:
CPU (vCPU)Memory Options (MB)Best For
256 (.25 vCPU)512, 1024, 2048Minimal services
512 (.5 vCPU)1024, 2048, 3072, 4096Light services
1024 (1 vCPU)2048, 3072, 4096, 5120, 6144, 7168, 8192Standard services
2048 (2 vCPU)4096-16384 (1024 MB increments)Medium workloads
4096 (4 vCPU)8192-30720 (1024 MB increments)Heavy workloads
See AWS Fargate pricing for cost estimates.
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):
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)}
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.
Building a deployed server that needs OAuth with a third-party service (e.g., Slack, Google)? See the OAuth for Deployed MCP Servers guide for the dual-auth architecture and token storage patterns.

AWS cross-account roles (hosted only)

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
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
VariableDescription
RUNLAYER_DEPLOYMENT_IDDeployment UUID — pass as ExternalId when assuming customer roles
RUNLAYER_DEPLOYMENT_TASK_ROLE_ARNThis 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. secretsRUNLAYER_AWS_ROLE_SECRETS)
Customer setup checklist (per role ARN)
StepAction
1Create or choose a least-privilege IAM role in your AWS account
2Attach a permissions policy granting only what the connector needs (S3, Secrets Manager, etc.)
3Add a trust policy allowing only the Runlayer deployment task role ARN with sts:ExternalId = deployment UUID
4Add the role ARN to infrastructure.aws.assume_roles.<name> in runlayer.yaml
5Deploy and copy task_role_arn from CLI output, deployment page, or API
Where to find the assuming principal
SourceField
runlayer deploy success outputtask_role_arn
Deployment API / dashboardtask_role_arn
Running containerRUNLAYER_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
{
  "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.
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.
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
$$DEPLOYMENT_URL is a special variable that resolves to your deployment’s URL. OAuth servers commonly use it as their issuer/base URL.
Example with CLI substitution:
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:
# 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:
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}
Important: Use double $$ ($$DEPLOYMENT_URL), not single $.

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:
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:
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>
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.

deploy init

Initialize a new deployment and generate configuration file.
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.
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.
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.
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)

deploy destroy

Teardown deployment and destroy infrastructure.
uvx runlayer deploy destroy --config runlayer.yaml --host <runlayer-url>
This action is destructive and cannot be undone. All infrastructure resources will be permanently deleted.
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:
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:
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:
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:
# 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
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.

Example: OAuth MCP Server

OAuth-enabled MCP server with unauthenticated OAuth routes:
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:
ToggleCarriesWhen to use
Forward identity headersPlain 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.
  • 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.

Headers sent

HeaderDescriptionSent for
X-Runlayer-Subject-TypeOne of user, agent, oboalways
X-Runlayer-Org-IdRunlayer organization UUIDalways
X-Runlayer-User-EmailEnd-user emailuser, obo
X-Runlayer-User-IdRunlayer user UUIDuser, obo
X-Runlayer-Agent-IdAgent account UUIDagent, obo
X-Runlayer-Agent-NameAgent display nameagent, obo

Subject types

Subject typeWhen it occurs
userA human end-user calling directly through their MCP client (Cursor, Claude Desktop, etc.).
agentA standalone agent account (machine-to-machine) calling with its own credentials. No human in the loop.
oboAn agent calling on behalf of a delegating user. Both agent identity and the delegator’s user identity are forwarded.

Example requests

Human user:
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):
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):
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:
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:
ClaimValue
alg (header)EdDSA
kid (header)Stable fingerprint of the signing key.
issRunlayer instance URL.
audrunlayer: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 / expIssued-at + expiry; TTL is 5 minutes.
jtiUnique per request.
subuser_id when present, otherwise agent_id.
subject_typeuser, agent, or obo.
organization_idRunlayer organization UUID.
user_email, user_idPresent for user and obo subjects.
agent_id, agent_namePresent for agent and obo subjects.

Verifying the signed token

Python (FastAPI + PyJWT):
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):
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):
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):
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

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
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.
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)
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.
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.
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.
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
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.
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

Next Steps

MCP Builder Quickstart

Scaffold a custom MCP server fast with the MCP Builder prompt

OAuth for Deployed Servers

Dual-auth architecture and token storage for OAuth connectors

Connectors

View and manage your connectors

Security Best Practices

Secure your MCP deployments and integrations