# Deploy self-hosted

> Run Executor in Docker or on Cloudflare Workers: image defaults, volume mounts, bootstrap admin env vars, TLS/public-origin requirements, and sandbox network constraints.

- Repository: RhysSullivan/executor
- GitHub: https://github.com/RhysSullivan/executor
- Human docs: https://grok-wiki.com/public/docs/rhyssullivan-executor-564383868052
- Complete Markdown: https://grok-wiki.com/public/docs/rhyssullivan-executor-564383868052/llms-full.txt

## Source Files

- `apps/docs/hosted/docker.mdx`
- `apps/docs/hosted/cloudflare.mdx`
- `apps/host-selfhost/Dockerfile`
- `apps/host-selfhost/docker-compose.yml`
- `apps/host-selfhost/executor.config.ts`
- `apps/host-cloudflare/wrangler.jsonc`
- `apps/host-cloudflare/executor.config.ts`

---

---
title: "Deploy self-hosted"
description: "Run Executor in Docker or on Cloudflare Workers: image defaults, volume mounts, bootstrap admin env vars, TLS/public-origin requirements, and sandbox network constraints."
---

Executor ships two operator-run runtimes that expose the same surfaces (web UI, HTTP API, streamable-HTTP MCP, QuickJS code execution) without Executor Cloud: a single-container Docker image (`apps/host-selfhost`) and a Cloudflare Worker (`apps/host-cloudflare`). Both are single-tenant, disable stdio MCP spawning (`dangerouslyAllowStdioMCP: false`), and persist encrypted secrets with the encrypted-secrets plugin.

<Tabs>
<Tab title="Docker">

The Docker image bundles libSQL (SQLite), Better Auth, QuickJS, and the web SPA in one process. No external database or proxy is required.

</Tab>
<Tab title="Cloudflare Workers">

The Worker bundles D1 storage, R2 blob storage, QuickJS-WASM, and Workers Static Assets for the SPA. Authentication is Cloudflare Access only; there is no in-app login or bootstrap admin flow.

</Tab>
</Tabs>

| Surface | Docker (`host-selfhost`) | Cloudflare (`host-cloudflare`) |
| --- | --- | --- |
| Auth | Better Auth (email/password, API keys, MCP OAuth) | Cloudflare Access JWT on every API/MCP request |
| Storage | libSQL file under `/data` | D1 + R2 (`executor-blobs`) |
| Code execution | QuickJS in-process | QuickJS-WASM in the Worker |
| Default port / URL | `4788` (`http://localhost:4788`) | `executor-cloudflare.<subdomain>.workers.dev` |
| Admin bootstrap | Browser first-run or `EXECUTOR_BOOTSTRAP_ADMIN_*` env vars | `ADMIN_EMAILS` + Access policies |
| Public origin var | `EXECUTOR_WEB_BASE_URL` | `VITE_PUBLIC_SITE_URL` (optional) |
| Sandbox network opt-in | `EXECUTOR_ALLOW_LOCAL_NETWORK=true` | `ALLOW_LOCAL_NETWORK=true` |

```mermaid
flowchart TB
  subgraph docker["Docker container (host-selfhost)"]
    SPA_D["Web SPA"]
    API_D["HTTP API /api/*"]
    MCP_D["MCP /mcp"]
    QJS_D["QuickJS executor"]
    DB_D["libSQL at /data/data.db"]
    SPA_D --> API_D
    API_D --> QJS_D
    MCP_D --> QJS_D
    API_D --> DB_D
  end

  subgraph cf["Cloudflare Worker (host-cloudflare)"]
    ACCESS["Cloudflare Access"]
    SPA_C["Static Assets (dist/)"]
    API_C["Worker /api/*"]
    MCP_C["Worker /mcp + McpSessionDO"]
    QJS_C["QuickJS-WASM"]
    D1["D1 (executor)"]
    R2["R2 (executor-blobs)"]
    ACCESS --> SPA_C
    ACCESS --> API_C
    ACCESS --> MCP_C
    API_C --> QJS_C
    MCP_C --> QJS_C
    API_C --> D1
    API_C --> R2
  end
```

## Docker

<Steps>
<Step title="Run the published image">

<CodeGroup>
```bash title="Published GHCR image"
docker run -d \
  --name executor-selfhost \
  -p 4788:4788 \
  -v executor-data:/data \
  ghcr.io/rhyssullivan/executor-selfhost:latest
```

```bash title="Build from a clone (apps/host-selfhost)"
docker compose up -d --build
```
</CodeGroup>

Open `http://localhost:4788`. A bare run needs no env vars: the container generates and persists session and encryption keys under `/data` on first boot.

</Step>

<Step title="Verify health">

The image healthcheck probes `GET /api/health` on port `4788`.

```bash
curl -sf http://localhost:4788/api/health
```

</Step>

<Step title="Create the admin">

Without bootstrap env vars, the first account created in the browser becomes the owner. After that, new users join only through single-use invite links from the **Admin** page.

For headless deploys, set both bootstrap variables before start (see [Bootstrap admin](#bootstrap-admin-docker)).

</Step>
</Steps>

### Image defaults

The multi-stage `apps/host-selfhost/Dockerfile` produces a distroless runtime image:

| Setting | Default |
| --- | --- |
| Base runtime | `gcr.io/distroless/cc-debian12` with Bun copied from `oven/bun:1` |
| `NODE_ENV` | `production` |
| `EXECUTOR_HOST` | `0.0.0.0` (bind all interfaces inside the container) |
| `PORT` | `4788` |
| `EXECUTOR_DATA_DIR` | `/data` |
| Entrypoint | `bun run dist-server/serve.js` |
| Published image | `ghcr.io/rhyssullivan/executor-selfhost` (`latest` for stable, `beta` for prereleases) |

<Note>
The local CLI daemon (`executor install`) listens on `17888` by default. The self-hosted Docker image uses `4788`; map the host port accordingly.
</Note>

### Volume mounts

Mount `/data` so the SQLite database and generated keys survive restarts and upgrades:

```bash
-v executor-data:/data          # named volume (docker-compose default)
-v /srv/executor:/data          # host path
```

| Path in container | Contents |
| --- | --- |
| `/data/data.db` | libSQL database (`EXECUTOR_DB_PATH` overrides the file path) |
| `/data/auth-secret.key` | Generated `BETTER_AUTH_SECRET` when unset |
| `/data/secret.key` | Generated `EXECUTOR_SECRET_KEY` when unset |

Back up by snapshotting the volume or copying `/data`.

## Cloudflare Workers

<Steps>
<Step title="Install dependencies and log in">

From the repo root:

```bash
bun install
cd apps/host-cloudflare
bunx wrangler login
```

</Step>

<Step title="Run deploy setup">

```bash
bun run deploy:setup
```

`deploy:setup` (`scripts/deploy.sh`) is idempotent. It:

1. Verifies `wrangler` login
2. Creates or reuses the `executor` D1 database and writes its `database_id` into `wrangler.jsonc`
3. Generates and uploads `EXECUTOR_SECRET_KEY` via `wrangler secret put` if not already set
4. Builds the SPA (`vite build` → `dist/`)
5. Deploys the Worker

The script also provisions R2 (`executor-blobs`) and a Durable Object (`McpSessionDO`) per `wrangler.jsonc`.

</Step>

<Step title="Enable Cloudflare Access">

Until the Worker sits behind an Access application, API and MCP routes return `401`. In the Zero Trust dashboard:

1. **Access → Applications → Add an application → Self-hosted**
2. Application domain: `executor-cloudflare.<your-subdomain>.workers.dev`
3. Add an Access policy (for example, emails ending in `@yourcompany.com`)
4. Copy the application **Audience (AUD)** tag, then redeploy:

```bash
bunx wrangler deploy \
  --var ACCESS_AUD:<aud> \
  --var ACCESS_TEAM_DOMAIN:<your-team>.cloudflareaccess.com
```

You can also set `ACCESS_AUD` and `ACCESS_TEAM_DOMAIN` in `wrangler.jsonc` and redeploy.

</Step>

<Step title="Grant admin role">

Set `ADMIN_EMAILS` to a comma-separated list of emails that receive the admin role after Access authentication:

```bash
bunx wrangler deploy --var ADMIN_EMAILS:admin@example.com,ops@example.com
```

</Step>
</Steps>

<Warning>
`ENABLE_DEV_AUTH=true` bypasses Access and treats every request as a fixed dev admin. Use only in local `wrangler dev` with `.dev.vars`. Never set it on a deployed Worker that is not already behind Access.
</Warning>

### Worker routing

`wrangler.jsonc` serves the built SPA from `./dist` with `single-page-application` fallback. `run_worker_first` routes `/api/*`, `/mcp`, and `/mcp/*` to the Worker; client routes like `/policies` fall through to the SPA.

## Bootstrap admin (Docker)

Docker supports two admin paths. Cloudflare has no equivalent: identity and membership come from Access.

**Browser first-run (default).** On a fresh instance with no bootstrap env vars, boot creates a single organization with zero members. The first signup through the setup screen claims ownership.

**Headless bootstrap.** Set both email and password to pre-create the admin at boot:

<ParamField body="EXECUTOR_BOOTSTRAP_ADMIN_EMAIL" type="string">
Email for the bootstrap admin. Must be set together with `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD`.
</ParamField>

<ParamField body="EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD" type="string">
Password for the bootstrap admin. Must be set together with `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL`.
</ParamField>

<ParamField body="EXECUTOR_BOOTSTRAP_ADMIN_NAME" type="string" default="Admin">
Display name for the bootstrap admin.
</ParamField>

<RequestExample>
```bash
docker run -d \
  -p 4788:4788 \
  -v executor-data:/data \
  -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=you@example.com \
  -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD='change-me-to-something-strong' \
  -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \
  ghcr.io/rhyssullivan/executor-selfhost:latest
```
</RequestExample>

<Info>
`BETTER_AUTH_SECRET` and `EXECUTOR_SECRET_KEY` are optional on Docker: when unset, random keys are generated and persisted under `/data`. Set them explicitly to manage rotation and to keep secrets readable if you move the data directory.
</Info>

## Public origin and TLS

Executor builds absolute links for OAuth redirects, MCP OAuth metadata, and connect-card URLs from a configured public origin. It does **not** derive this from the request `Host` header (that would allow host-header injection).

### Docker

<ParamField body="EXECUTOR_WEB_BASE_URL" type="string">
Public URL browsers use (scheme + host + port). Must exactly match the address loaded in the browser.
</ParamField>

<Warning>
Behind a reverse proxy or TLS terminator, set `EXECUTOR_WEB_BASE_URL` to the exact public URL (for example `https://executor.example.com`). A mismatch causes Better Auth to reject logins with `403 Invalid origin`.
</Warning>

When `EXECUTOR_WEB_BASE_URL` is unset, the resolver checks platform-injected vars (`RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_URL`, `FLY_APP_NAME`, and others) before falling back to `http://localhost:<PORT>`.

### Cloudflare

<ParamField body="VITE_PUBLIC_SITE_URL" type="string">
Optional canonical web base URL. When unset, the Worker derives the origin from each request's URL (Cloudflare-set, not spoofable via `Host`).
</ParamField>

Set `VITE_PUBLIC_SITE_URL` only when you need a pinned canonical URL, for example behind a proxy that rewrites `Host`.

## Sandbox network constraints

Sandboxed code (QuickJS executions and tool calls that use the hosted HTTP client) outbound requests pass through a network guard. By default, local and private addresses are blocked.

| Runtime | Opt-in variable | Default |
| --- | --- | --- |
| Docker | `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` (unset or any value other than `"true"`) |
| Cloudflare | `ALLOW_LOCAL_NETWORK` | `false` |

When the guard is active (`allowLocalNetwork: false`), outbound HTTP/HTTPS requests are blocked if they target:

- `localhost` or `*.localhost`
- Private, loopback, link-local, or multicast IPv4/IPv6 ranges
- Cloud metadata hostnames (`metadata.google.internal`, `169.254.169.254`, and similar)
- Hostnames that DNS-resolve to any of the above (rebinding protection on Docker)

Only `http:` and `https:` protocols are allowed.

<Warning>
Set `EXECUTOR_ALLOW_LOCAL_NETWORK=true` or `ALLOW_LOCAL_NETWORK=true` only when you trust the code running in the sandbox and need it to reach internal services. Keep the default off for adversarial or LLM-generated code.
</Warning>

## Environment reference

### Docker

| Variable | Default | Purpose |
| --- | --- | --- |
| `PORT` | `4788` | HTTP listen port |
| `EXECUTOR_HOST` | `0.0.0.0` in image; `127.0.0.1` in bare `loadConfig()` | Bind address |
| `EXECUTOR_DATA_DIR` | `/data` in image | Data directory for DB and generated keys |
| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite file path |
| `EXECUTOR_WEB_BASE_URL` | platform-detected or `http://localhost:<PORT>` | Public browser URL |
| `BETTER_AUTH_SECRET` | generated in `/data` | Session secret (32+ chars if set explicitly) |
| `EXECUTOR_SECRET_KEY` | generated in `/data` | At-rest secret encryption key |
| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Headless admin email |
| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Headless admin password |
| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Headless admin display name |
| `EXECUTOR_ORG_NAME` | `Default` | Single org display name |
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for org-prefixed routes |
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandbox code to reach private networks |

Copy `apps/host-selfhost/.env.example` to `.env` beside `docker-compose.yml` for optional overrides.

### Cloudflare (`wrangler.jsonc` vars and secrets)

| Name | Kind | Purpose |
| --- | --- | --- |
| `EXECUTOR_SECRET_KEY` | **secret** (`wrangler secret put`) | Required. Encrypts stored secrets at rest in D1 |
| `ACCESS_TEAM_DOMAIN` | var | Zero Trust team domain |
| `ACCESS_AUD` | var | Access application audience tag |
| `ACCESS_NAME_CLAIM` | var | JWT claim for display name (default `name`) |
| `ACCESS_GROUPS_CLAIM` | var | JWT claim for groups (default `groups`) |
| `ADMIN_EMAILS` | var | Comma-separated admin emails |
| `SELF_HOSTED_ORG_ID` | var | Single org id (default `default`) |
| `SELF_HOSTED_ORG_NAME` | var | Single org name (default `Default`) |
| `SELF_HOSTED_ORG_SLUG` | var | Org URL slug (default `default`) |
| `ALLOW_LOCAL_NETWORK` | var | Sandbox private-network opt-in |
| `VITE_PUBLIC_SITE_URL` | var | Optional pinned public origin |
| `ENABLE_DEV_AUTH` | var | Local dev only; bypasses Access |

## Connect agents and clients

Both runtimes expose streamable-HTTP MCP at `/mcp`:

- Docker: `http://localhost:4788/mcp` (or your public `EXECUTOR_WEB_BASE_URL` + `/mcp`)
- Cloudflare: `https://executor-cloudflare.<subdomain>.workers.dev/mcp` (authenticated through Access)

See [MCP proxy](/mcp-proxy) for how the endpoint fronts your integrations, and [Connect MCP clients](/connect-mcp-clients) for client wiring.

## Related pages

<CardGroup>
<Card title="Configuration reference" href="/configuration-reference">
Environment variables, runtime paths, ports, and client overrides across all runtimes.
</Card>
<Card title="Connect MCP clients" href="/connect-mcp-clients">
Wire Cursor, Claude Code, and other MCP clients to your self-hosted `/mcp` endpoint.
</Card>
<Card title="Configure credentials" href="/configure-credentials">
Set up credential providers and connections after the instance is running.
</Card>
<Card title="Hosted cloud" href="/hosted-cloud">
How Executor Cloud differs from Docker and Cloudflare self-host deployments.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Recovery for port conflicts, invalid-origin errors, OAuth behind proxies, and daemon issues.
</Card>
</CardGroup>
