---
title: Self-host with Docker
description: "Run the whole hosted server in one container, with an embedded database and no separate Postgres, queue or proxy, on your own infrastructure."
---

Self-host is the hosted server on your own infrastructure. One container holds
the API, the MCP endpoint, authentication, the app runtime and the dashboard,
over an embedded PGlite database. There is no separate database, worker, queue
or proxy to run.

It has the same organizations, invitations and browser sign-in as hosted. It
does not offer the hosted Google and GitHub sign-in buttons.

## Configure it

No environment variables are needed for local Docker or a Railway service with a
public domain. On first boot, Executor generates separate session and encryption
keys and saves them in the data volume. Later boots reuse those files.

These settings override the defaults:

| Variable                             | Required | Purpose                                                                  |
| ------------------------------------ | -------- | ------------------------------------------------------------------------ |
| `BETTER_AUTH_URL`                    | no       | Public origin. Defaults to Railway's HTTPS domain, then `http://localhost:4400` (or `PORT`). |
| `BETTER_AUTH_SECRET`                 | no       | Override the saved session secret. At least 32 characters. Rotating it signs everyone out. |
| `EXECUTOR_ENCRYPTION_KEY`            | no       | Override the saved credential encryption key. Exactly 64 hexadecimal characters. |
| `EXECUTOR_ENVIRONMENT`               | no       | Label for this deployment. The default is `self-host`.                   |
| `EXECUTOR_APP_UI_BASE_URL`           | no       | HTTPS origin that serves app web pages. See below.                       |
| `EXECUTOR_APPS_ALLOW_PRIVATE_FETCH`  | no       | Let app code reach your private network. See below.                      |
| `EXECUTOR_APP_WORKERS` | no | Most app and data Workers kept loaded at once. The default is `64`. See below. |
| `EXECUTOR_APP_WORKER_IDLE_SECONDS` | no | Unload an app or data Worker not called for this long; `0` keeps idle Workers loaded up to the limit. The default is `300`. See below. |
| `EXECUTOR_OAUTH_CLIENT_METADATA_URL` | no       | Identify Executor to OAuth services by a client metadata document instead of registering a client. Off by default. See below. |
| `EXECUTOR_CATALOG_URL`               | no       | `index.json` of the official app feed that onboarding and **Add app** list. The default is `https://catalog.executor.sh/index.json`; `off` lists only the built-in Executor app, for an instance without outbound internet access. |
| `EXECUTOR_REGISTRY_URL`              | no       | Origin of the public app registry. The default is `https://api.executor.sh`. |
| `EXECUTOR_TOOL_LISTING_FRESH_SECONDS` | no     | Reuse an app's evaluated tool list this long before refreshing it in the background. The default is `30`. |
| `EXECUTOR_TOOL_LISTING_RENEW_SECONDS` | no     | Renew the tool lists search serves in the background once they are this old. The default is `86400`. |
| `EXECUTOR_TOOL_LISTING_MAX_AGE_SECONDS` | no  | Never serve an evaluated tool list older than this; `0` evaluates every list. The default is `604800`. |
| `EXECUTOR_TOOL_LISTING_LOAD_SECONDS` | no     | Stop evaluating an app's tool list after this long when no request is waiting for it; capped at the maximum age. The default is `45`. |
| `EXECUTOR_EVALUATION_MEMORY_MB`      | no       | Memory for kept tool lists and app declarations. The default is `256`.  |
| `EXECUTOR_NPM_REGISTRY`              | no       | npm registry that app builds install packages from. The default is `https://registry.npmjs.org`. |
| `DO_NOT_TRACK` or `EXECUTOR_DISABLE_ANALYTICS` | no | Set to `1` to turn off anonymous usage analytics and feedback. See [usage analytics](/run/usage-analytics). |
| `SSO_DISCOVERY_URL`                  | no       | OIDC discovery document. Leave it empty to keep SSO off.                 |
| `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` | no       | Credentials for that identity provider.                                  |
| `SSO_ALLOWED_DOMAINS`                | no       | Comma-separated email domains allowed to join through SSO.               |

App code runs in an isolate whose `fetch` reaches only public addresses, the same
as Executor Cloud. Requests to your own `BETTER_AUTH_URL` origin go straight to
the server and do not use the network. Thus the bundled Executor app works when
that origin resolves to a private address, such as a Tailscale name. To let app
code reach other private addresses, set `EXECUTOR_APPS_ALLOW_PRIVATE_FETCH=true`.
The default is `false`. Every app in the instance shares that network position,
so grant it deliberately.

Each app runs in its own Worker for each set of accounts it is called with, and
each loaded Worker holds its own memory. The server keeps at most
`EXECUTOR_APP_WORKERS` of them loaded. When more are needed, it unloads the
ones idle longest; their next call loads them again, which takes longer than a
call to a loaded Worker. A Worker that is still handling a call is never
unloaded. Raise the limit if many apps are used at the same time and the
server has memory to spare; lower it on a small machine.

A Worker that has not been called for `EXECUTOR_APP_WORKER_IDLE_SECONDS`
(five minutes by default) is unloaded even below the limit, so an app called
once does not keep its memory. Raise it if apps are called every few minutes
and the slower first call matters more than memory. Set it to `0` to keep idle
Workers loaded until the limit unloads them.

Apps with a database also run their calls in a data Worker, for the accounts
they were last called with. Data Workers count against `EXECUTOR_APP_WORKERS`
too, so a call of such an app can keep two Workers loaded.

`BETTER_AUTH_URL` must match the scheme, host and port you actually use. If it
does not, browser sign-in is rejected as an invalid origin. Railway detection uses `RAILWAY_PUBLIC_DOMAIN`. Origins are never inferred
from a request header. Set `BETTER_AUTH_URL` when you use a custom domain.

On a home or office network without TLS, set `BETTER_AUTH_URL` to the plain HTTP
address you open, such as `http://192.168.1.20:4400`. Browsers give such a page no
secure context, and the dashboard does not need one. App web pages still need an
HTTPS origin; see [Serving app web pages](#serving-app-web-pages).

Keep `<BETTER_AUTH_URL>/api/oauth/callback` reachable; connecting a provider
account by OAuth uses it. The SSO callback is
`<BETTER_AUTH_URL>/api/auth/callback/sso`.

Executor registers its own OAuth client with each service that offers dynamic
registration. Services that accept an OAuth client ID metadata document, such as
many MCP servers, can identify Executor by a public document instead. Set
`EXECUTOR_OAUTH_CLIENT_METADATA_URL` to `<BETTER_AUTH_URL>/oauth/client-metadata.json`
and Executor serves that document. It lists a callback on the document's own origin,
since some services accept no other: `EXECUTOR_OAUTH_CALLBACK_URL` when it is on that
origin, otherwise `/api/oauth/callback` there. The service fetches it
from the internet, so turn this on only when that URL is reachable publicly over
HTTPS. Startup fails for any other address on your own origin. Clients your users
already registered or entered stay in use when you turn this on.

The bundled Motel collector records traces by default. You can export to your own collector instead. See
[Tracing](/run/tracing).

## Run it

On Linux AMD64 or ARM64. The beta tag is the newest published beta release:

```bash
docker pull ghcr.io/usefulsoftwareco/executor-selfhost:beta
docker run -d --name executor-v2 --init --restart unless-stopped \
  -p 127.0.0.1:4400:4400 -v executor-v2-data:/app/data \
  ghcr.io/usefulsoftwareco/executor-selfhost:beta
```

Open `http://localhost:4400` and complete the first-run setup.
The volume retains your data and keys across container restarts.

To pin a release instead, use a version listed on github.com/UsefulSoftwareCo/executor/releases, such as 2.0.0-beta.N, as the tag in both commands. Pull the tag again before recreating the container to update. Back up the volume before upgrading.

```bash
docker pull ghcr.io/usefulsoftwareco/executor-selfhost:<version>
```

The container publishes port `4400` on loopback only, as
`127.0.0.1:4400`. Put your own TLS terminator in front of it and forward to that
port, and add `-e BETTER_AUTH_URL=<origin>` to the run command with the exact
origin people will open. [Private access with Tailscale](#private-access-with-tailscale)
shows one way. Startup initializes the embedded database before it opens HTTP, so the
first boot takes a moment.

Stop it with:

```bash
docker stop executor-v2
```

The first person to finish setup becomes the owner, and one organization is
created. After that, people join through invitations, or through SSO when you
have configured it.

## Deploy on Railway

Create a Railway service from this image:

```bash
ghcr.io/usefulsoftwareco/executor-selfhost:beta
```

1. Attach a new persistent volume at `/app/data`.
2. Generate a public domain in the service's networking settings. Route it to port `8080`, which Railway supplies through `PORT`. If you override `PORT`, use that value.
3. Set the Railway healthcheck path to `/health`, with a startup timeout of 120 seconds.
4. Deploy (or redeploy if the service already started). This loads the new public domain into the server. Open that domain to create the first administrator.

No database service or secret variables are needed. Executor reads
`RAILWAY_PUBLIC_DOMAIN` and `PORT`. The container prepares the root-owned volume,
then runs the server as the unprivileged `executor` user. You do not need
`RAILWAY_RUN_UID=0` or a custom start command. Keep one replica per volume.

For a custom domain, set `BETTER_AUTH_URL` to that exact HTTPS origin. App web
pages need the additional wildcard domain described below; the dashboard, API,
and MCP endpoint use the service domain.

## Private access with Tailscale

Tailscale Serve gives the server a private HTTPS address on your tailnet, with
no public DNS, port forwarding or certificate to manage. Install Tailscale on
the host that runs Docker, keep the container on loopback, and point Serve at
it. This example uses HTTPS port `4443`, for a host where another service
already uses `443`:

```bash
docker run -d --name executor-v2 --init --restart unless-stopped \
  -p 127.0.0.1:4400:4400 -v executor-v2-data:/app/data \
  -e BETTER_AUTH_URL=https://<machine>.<tailnet>.ts.net:4443 \
  ghcr.io/usefulsoftwareco/executor-selfhost:beta
tailscale serve --bg --https=4443 http://127.0.0.1:4400
```

`tailscale serve status` shows the address. `BETTER_AUTH_URL` must match it
exactly, including a port other than `443`; without the port, browser sign-in
is rejected as an invalid origin. Changing Serve needs administrator rights on
the host; on Synology, run it over SSH with `sudo`.

The MCP endpoint is then `https://<machine>.<tailnet>.ts.net:4443/mcp`. Every
device that opens the dashboard or runs an MCP client must be on the tailnet.
For Codex:

```bash
codex mcp add executor --url 'https://<machine>.<tailnet>.ts.net:4443/mcp'
codex mcp login executor
```

What works with a tailnet-only address:

| Feature | Tailnet only |
| --- | --- |
| Dashboard, MCP endpoint, and client sign-in | Yes |
| Connecting a provider account by OAuth | Yes. The provider redirects your browser, which is on the tailnet, to `/api/oauth/callback`. |
| `EXECUTOR_OAUTH_CLIENT_METADATA_URL` | No. The service fetches the document from the internet. |
| App webhooks | No. The service delivers them to your origin, so it must reach it. |
| App web pages | No. They need a wildcard domain and certificate, and Tailscale certificates cover only the machine's own name. See [Serving app web pages](#serving-app-web-pages). |

Tailscale Funnel can publish the address on the internet for the features
that need it. Funnel accepts only ports `443`, `8443` and `10000`.

## Storage

Everything that must survive a restart lives in the named volume `executor-v2-data`,
mounted at `/app/data`. This includes the database, app source and builds, app
data and generated keys (`auth-secret.key` and `encryption.key`).
The key files are readable only by the server user. Back up the whole volume.

Motel stores telemetry separately at `/app/motel-data`. Replacing the container
discards that telemetry by default. Mount a separate volume there if you need
to retain it. Motel keeps telemetry until the first of three bounds is reached:
seven days, one million spans or 1 GiB, evicting the oldest traces first. A busy
server reaches 1 GiB first, so it may keep only the last hours or minutes of
traces. When exports arrive faster than Motel can store them, it refuses the
excess rather than holding it in memory, and it counts every export it does not
store. The server log reports those losses.

If you supply keys through environment variables, keep those values in your
secret manager as well; Executor does not save those overrides to disk. Keep
the same key source across upgrades. If a saved key is missing or invalid for
an existing database, startup fails instead of creating a replacement. Restore
the original key. Changing the encryption key makes existing credentials unreadable.

Back up by snapshotting that volume. Do not remove it when you update the image.
Before updating, stop the container and back up the volume. Pull the new image,
remove only the stopped container with `docker rm executor-v2`, and repeat the
run command with the same volume and secrets.

## Build from source

The public repository includes a Compose setup that builds this release locally:

```bash
git clone --depth 1 --branch 'executor@2.0.0-beta.41' https://github.com/UsefulSoftwareCo/executor.git executor-v2
cd executor-v2
docker compose -f apps/hosted/self-host/compose.yaml build --build-arg EXECUTOR_BUILD_VERSION=2.0.0-beta.41
docker compose -f apps/hosted/self-host/compose.yaml up -d
```

Compose also works without environment variables. This setup uses a separate named
volume, `pglite-data`; do not confuse it with the published-image example above.

## Serving app web pages

Some apps ship a web page. Set `EXECUTOR_APP_UI_BASE_URL` to an HTTPS origin
with no trailing slash, and point a wildcard DNS record and certificate at your
proxy:

```bash
EXECUTOR_APP_UI_BASE_URL=https://apps.example.net
```

Each page is served at `<app-slug>--<organization-slug>.<base>`, forwarded to
container port 4400. That whole first label must fit in 63 characters, so keep
app and organization slugs short. On localhost the origin is derived for you and
you do not need to set this.

## Running without Docker

The source checkout includes a native development server that runs with Bun.
The published Docker image uses workerd. Native development data persists under
`.local/hosted`; set `EXECUTOR_DATA_DIR` to choose a different parent directory.
`HOST` defaults to `0.0.0.0` and `PORT` to `4400`.

Self-host needs no `DATABASE_URL`, no separate Postgres, and no migration
command.

## Connect an agent

The MCP endpoint is `<your origin>/mcp`. Sign-in happens in the browser, the
same way as hosted. See [Add an MCP client](/mcp-clients).

## Move from a local Executor

A self-hosted server starts empty. Nothing is copied from the local Executor
on your computer or from Executor 1, and the two keep running separately.

1. **Add your apps again.** Install them from **Add app**. To deploy your own,
   sign in with `executor apps login --host <your origin>`, then pass the same
   `--host` to the other `executor apps` commands.
2. **Connect your accounts again.** Credentials are encrypted with each
   server's own key and do not transfer. Connect each account from its app's
   page.
3. **Back up the new server.** Its apps, accounts, credentials and app data all
   live in the data volume, together with the keys that decrypt them. Back up
   the whole volume as described in [Storage](#storage).
4. **Replace each client's old entry.** Remove the local or Executor 1 entry
   first; one that starts `executor mcp` belongs to Executor 1, which served
   MCP over stdio. For Codex, run `codex mcp remove executor`, then add your
   endpoint as in [Connect an agent](#connect-an-agent). For Claude Code, run
   `claude mcp remove executor --scope user`. The [Executor 1
   commands](/run/cli#coming-from-executor-1) map to their replacements.
5. **Start a new client session.** Most clients load MCP servers only when a
   session starts.
6. **Check it works.** Ask the agent to [search the tools](/mcp-clients#check-that-it-worked),
   then to call one read-only tool from an app you added, such as a list or
   search tool.
