Self-host with Docker
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. |
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.
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 it
On Linux AMD64 or ARM64. The beta tag is the newest published beta release:
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:betaOpen 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.
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
shows one way. Startup initializes the embedded database before it opens HTTP, so the
first boot takes a moment.
Stop it with:
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:
ghcr.io/usefulsoftwareco/executor-selfhost:beta- Attach a new persistent volume at
/app/data. - Generate a public domain in the service’s networking settings. Route it to port
8080, which Railway supplies throughPORT. If you overridePORT, use that value. - Set the Railway healthcheck path to
/health, with a startup timeout of 120 seconds. - 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:
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:
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. |
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:
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 -dCompose 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:
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.
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.
- 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--hostto the otherexecutor appscommands. - 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.
- 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.
- Replace each client’s old entry. Remove the local or Executor 1 entry
first; one that starts
executor mcpbelongs to Executor 1, which served MCP over stdio. For Codex, runcodex mcp remove executor, then add your endpoint as in Connect an agent. For Claude Code, runclaude mcp remove executor --scope user. The Executor 1 commands map to their replacements. - Start a new client session. Most clients load MCP servers only when a session starts.
- Check it works. Ask the agent to search the tools, then to call one read-only tool from an app you added, such as a list or search tool.