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_APP_WORKERS no Most app Workers kept loaded at once, besides one data Worker per app with a database. The default is 32. See below.
EXECUTOR_REGISTRY_URL no Origin of the public app registry. The default is https://v2.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_MAX_AGE_SECONDS no Never serve an evaluated tool list older than this; 0 evaluates every list. The default is 60.
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.
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.

Apps with a database also keep one data Worker loaded each, for the accounts they were last called with. These are not counted against EXECUTOR_APP_WORKERS, so add one Worker per app with a database when you size memory.

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.

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.

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.4 on Linux AMD64 or ARM64:

docker pull ghcr.io/usefulsoftwareco/executor-selfhost:2.0.0-beta.4
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.4

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.

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

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.4' 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.4
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?