Skip to content
Documentation
On this page

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_TRUSTED_PROXIES no Comma-separated addresses of your reverse proxies, so rate limits count each client behind them. List only proxies. 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

Use version 2.0.0-beta.41 on Linux AMD64 or ARM64:

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

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

To follow the beta channel, use this image tag in the run command. Pull it again before recreating the container to update. Back up the volume before upgrading.

docker pull ghcr.io/usefulsoftwareco/executor-selfhost:beta

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. Startup initializes the embedded database before it opens HTTP, so the first boot takes a moment.

Sign-in and the other auth routes are rate-limited per client address. Behind a reverse proxy every request comes from the proxy, so all clients share its limits. Set EXECUTOR_TRUSTED_PROXIES to the addresses your proxies connect from; the Docker image and the Compose build read it. A request from a listed address takes its client from X-Forwarded-For, read from the right past every listed address. Each proxy must append the address it received the request from, as Caddy, Traefik and nginx’s $proxy_add_x_forwarded_for do. When every listed address is such a proxy and no client connects from one, a client can’t pick its own address by sending that header. Requests from any other address, or with the setting empty, use the connection’s address.

List each proxy’s exact address, or a range that holds only your proxies. Anything that connects from a listed address can choose the address it is limited and recorded under, so do not list a Docker subnet other containers share, and never list 0.0.0.0/0 or ::/0.

  • A proxy on the host connects from the gateway of the container’s Docker network, often 172.17.0.1 for docker run and different under Compose. docker inspect on the container shows it as each network’s Gateway. The loopback-only port keeps remote clients off, but every process on the host arrives from that same gateway, so listing it trusts all of them.
  • A proxy container connects from its own address. Give it a fixed address and list that.
  • For Cloudflare Tunnel, list the address cloudflared connects from, by the same rules: Cloudflare appends each visitor’s address to X-Forwarded-For and cloudflared passes it on.

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:2.0.0-beta.41
  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.

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

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.

Was this page helpful?