OpenWorkers runs its own control plane on itself: the API worker serves both the REST API and the dashboard UI, deployed on the platform it manages. This document sets up that mode end to end, from an empty machine to a dashboard served by the runner.
The API also runs as a plain Bun server. That is a development convenience covered in Development loop, not a deployment target.
| Process | Language | Role |
|---|---|---|
| postgres | - | Single source of truth: workers, users, environments, assets metadata |
| runner | Rust | Workers, cron, logs, public HTTP/TLS |
| postgate | Rust | HTTP proxy in front of postgres, used by the API dev loop and for user databases |
ow (CLI) |
Rust | Migrations, users, workers, bindings, over a direct database connection |
| openworkers-api | Bun | REST API and dashboard UI, deployed as a worker |
- Rust (stable). The runner, postgate and the CLI are Rust.
- Bun. The API is TypeScript.
- PostgreSQL 15+, in a container or native (step 2).
- A TLS certificate and key for the runner (step 9).
Install the CLI:
cargo binstall openworkers-cli
# or from source
cargo install --git https://github.com/openworkers/openworkers-cliPrebuilt tarballs are listed in the CLI README.
Clone what you need:
mkdir openworkers && cd openworkers
git clone https://github.com/openworkers/openworkers-infra.git
git clone https://github.com/openworkers/openworkers-cli.git # migrations live here
git clone https://github.com/openworkers/openworkers-runner.git
git clone https://github.com/openworkers/openworkers-api.git
git clone https://github.com/openworkers/postgate.git # step 5Pick either way to run it; nothing below depends on the choice, only on the connection string.
cd openworkers-infra
cp .env.example .env # POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB at least
docker compose -f compose.yml -f compose.dev.yml up -d postgrescompose.dev.yml publishes PostgreSQL on 5432.
brew install postgresql@15
brew services start postgresql@15
createuser openworkers
createdb -O openworkers openworkersPostgres.app works the same way: start it, then run createuser and createdb
from /Applications/Postgres.app/Contents/Versions/latest/bin.
The rest of this document uses postgres://openworkers@localhost:5432/openworkers.
Add :password after the role name if yours has one.
ow alias set infra --db postgres://openworkers@localhost:5432/openworkers
ow infra migrate status
ow infra migrate runinfra is an alias name, and every command below is prefixed with it. Aliases
come in two kinds: a db alias (--db) opens a direct PostgreSQL connection,
an api alias (--api) talks to a running platform. Infrastructure commands
(migrate, users, setup-storage) require a db alias.
Aliases live in ~/.openworkers/config.json. Dropping the prefix falls back to
the default alias, which points at the hosted API, so keep the prefix on every
command.
Known issue: migration 28 fails under the CLI runner.
The CLI wraps each migration file in a transaction (sqlx). Migration 28 starts with
ALTER TYPE ... ADD VALUE, and PostgreSQL refuses to use a new enum value in the transaction that added it:unsafe use of new value "planetscale" of enum type. Everything before 28 is applied when this fires. Apply that one file with psql, which runs it in autocommit, then record it:DATABASE_URL=postgres://openworkers@localhost:5432/openworkers FILE=openworkers-cli/migrations/28_planetscale_provider.sql psql "$DATABASE_URL" -f "$FILE" psql "$DATABASE_URL" -c "INSERT INTO _sqlx_migrations (version, description, installed_on, success, checksum, execution_time) VALUES (28, 'planetscale provider', now(), true, decode('$(shasum -a 384 "$FILE" | cut -d' ' -f1)', 'hex'), 0)"The checksum has to be the SHA-384 of the file:
migrate runverifies applied migrations against it and refuses to continue on a mismatch. Then runow infra migrate runagain for whatever comes after 28.
This step decides what your dashboard shows, so read it before running it.
Migrations create one user row with the nil UUID
00000000-0000-0000-0000-000000000000, named __system__. It owns the platform's
shared resources: the openworkers-api database config, and every worker,
environment and storage config the CLI creates through a db alias.
ow infra users create <handle> --system creates nothing. It renames
__system__ to <handle>. Whoever logs in as <handle> is the platform.
(a) Claim it with your GitHub handle: merged identity.
ow infra users create <github-login> --systemGitHub sign-in looks up the GitHub numeric id first; finding none, it inserts a
user row keyed on the GitHub login, and the unique constraint on username
turns that insert into an update of the existing row. Your GitHub account is
then bound to the system user. You are root: your dashboard lists
openworkers-api and the rest of the platform resources alongside your own
workers. Convenient on a single-developer machine, and not cleanly reversible.
(b) Claim it headless with an email: separate identity.
ow infra users create platform@example.com --system --passwordPassword login matches username against the email, so the system user is
reachable only through that address and password. Your GitHub sign-in then
creates an ordinary user row with an empty dashboard: no openworkers-api, no
platform storage, only what that account creates. Recommended: it is the only
setup where you see the product as a user sees it.
--password prompts for the password on the terminal; a script cannot give it.
Without a password, this user cannot sign in to the dashboard. The CLI does not
need it.
Then point the alias at the claimed user. Every ow infra command creates
resources owned by it:
ow alias set infra \
--db postgres://openworkers@localhost:5432/openworkers \
--user platform@example.com \
--force--force overwrites the existing alias. --user is resolved to users.id by
exact username on every command.
Postgate proxies SQL over HTTP. The runner embeds it as a library, so a worker-mode
API reaches the platform database through its DATABASE binding and needs no
Postgate service for that. Postgate is needed for two other things:
- the dev loop (step 10), where the API queries postgres over HTTP
with a
pg_...token; - SQL run against a user database from the dashboard, which the API sends to
POSTGATE_URLwith a token derived fromPOSTGATE_SYSTEM_TOKEN_SECRET.
cd postgate
cat > .env << 'EOF'
DATABASE_URL=postgres://openworkers@localhost:5432/openworkers
POSTGATE_PORT=6080
EOF
cargo runSet the port explicitly: the binary defaults to 3000 while the rest of the stack
expects 6080 (the compose service sets it, and the API's POSTGATE_URL defaults
to http://localhost:6080).
Generate a token for the platform database, whose id is the nil UUID:
cargo run -- gen-token 00000000-0000-0000-0000-000000000000 api \
--permissions SELECT,INSERT,UPDATE,DELETEKeep the pg_... value for step 10.
Worker uploads store assets in an S3-compatible bucket. The CLI needs its credentials before it can create any storage config:
ow infra setup-storage \
--endpoint https://<account>.r2.cloudflarestorage.com \
--bucket openworkers-dev \
--access-key-id AKIA... \
--secret-access-key ...The credentials are written into the infra entry of
~/.openworkers/config.json: per machine and per alias, not in the database.
For a local machine, an S3-compatible server in a container is sufficient. For example, with RustFS:
docker run -d --name ow-s3 -p 9000:9000 \
-e RUSTFS_ACCESS_KEY=owlocal -e RUSTFS_SECRET_KEY=owlocal-secret-key \
rustfs/rustfs
# Create the bucket with a signed request
curl --aws-sigv4 "aws:amz:us-east-1:s3" --user owlocal:owlocal-secret-key \
-X PUT http://localhost:9000/openworkers-dev
ow infra setup-storage \
--endpoint http://localhost:9000 \
--bucket openworkers-dev \
--access-key-id owlocal \
--secret-access-key owlocal-secret-keyData in this container is lost when you remove it. The minio/minio image is
not available on Docker Hub.
Two failure modes:
Storage can only be configured for DB aliasesmeans the alias prefix is missing.ow setup-storage ...resolves the default alias, which is an api alias. It isow infra setup-storage ....Platform storage not configuredat the firststorage createmeans this step was skipped.storage createdefaults to--provider platformand takes its credentials from the alias;--provider s3with explicit--bucket,--endpoint,--access-key-idand--secret-access-keybypasses it.
Create the worker, its environment, and the two bindings:
ow infra env create openworkers-api-env
ow infra workers create openworkers-api
ow infra workers link openworkers-api openworkers-api-env
# The platform database config, created by migration 15 under this name
ow infra env bind openworkers-api-env DATABASE openworkers-api --type database
# Bucket holding the client bundle
ow infra storage create openworkers-api-storage
ow infra env bind openworkers-api-env ASSETS openworkers-api-storage --type assetsThe variables are listed in openworkers-api/README.md#environment-variables, which is the single source of truth for what is required in which mode. A worker deployment needs at least:
ow infra env set openworkers-api-env APP_URL https://dash.dev.localhost
ow infra env set openworkers-api-env POSTGATE_SYSTEM_TOKEN_SECRET --secret
ow infra env set openworkers-api-env JWT_ACCESS_SECRET --secret
ow infra env set openworkers-api-env JWT_REFRESH_SECRET --secret
# Same bucket as step 6: the API creates storage configs for its users
ow infra env set openworkers-api-env SHARED_STORAGE_BUCKET openworkers-dev
ow infra env set openworkers-api-env SHARED_STORAGE_ENDPOINT https://<account>.r2.cloudflarestorage.com
ow infra env set openworkers-api-env SHARED_STORAGE_PUBLIC_URL https://assets.example.com
ow infra env set openworkers-api-env SHARED_STORAGE_ACCESS_KEY_ID --secret
ow infra env set openworkers-api-env SHARED_STORAGE_SECRET_ACCESS_KEY --secret
# Without this pair, the sign-in page redirects to /sign-in?error=github-not-configured
ow infra env set openworkers-api-env GITHUB_CLIENT_ID Iv1....
ow infra env set openworkers-api-env GITHUB_CLIENT_SECRET --secretOmitting a value prompts for it with masked input, which keeps secrets out of the shell history.
Build and upload:
cd openworkers-api
bun install && bun run build
ow infra workers upload openworkers-api ./buildCreate and link the environment before the first upload: an upload that promotes the worker to a project inherits the environment automatically, while linking afterwards has to cascade it. See openworkers-api/DEPLOY.md.
The openworkers-api worker serves the dashboard UI itself. There is nothing
else to deploy.
The runner serves every worker, including the API:
cd openworkers-runner
cat > .env << 'EOF'
WORKER_DOMAINS=workers.dev.localhost
DATABASE_URL=postgres://openworkers@localhost:5432/openworkers
RUST_LOG=info
EOF
cargo run --features v8 --bin snapshot # first run, and after any runtime JS change
cargo run --features v8The runner listens publicly on 0.0.0.0:8081 (HTTP_ADDR). It resolves
workers by their configured subdomain or custom database domain. The loopback
listener 127.0.0.1:8080 accepts internal worker selectors and admin requests.
Cron scheduling and log persistence start in this process. Do not start the standalone logs or scheduler services. Only one runner may use the platform database at a time; stop it before starting a replacement.
To enable TLS, set both variables before starting the runner:
export HTTP_TLS_CERTIFICATE=/path/to/ssl.crt
export HTTP_TLS_KEY=/path/to/ssl.key
cargo run --features v8It listens on 0.0.0.0:8443 (HTTPS_ADDR). The certificate must cover the
configured dashboard and worker domains. The configuration above uses
dash.dev.localhost and *.workers.dev.localhost. Worker subdomains require
an explicit WORKER_DOMAINS setting.
Update the API's APP_URL and OAuth callback URLs to include :8443 when using
this port. The Compose dev overlay maps HTTPS to host port 443 instead; add
-f compose.tls.yml to load the certificate mounts.
openworkers-infra/run-dev.sh starts this runner on port 443 (HTTPS) and port
80 (HTTP) of all addresses: macOS lets a user bind a port below 1024 only on
0.0.0.0. It needs HTTP_TLS_CERTIFICATE and HTTP_TLS_KEY,
and it uses the runner's .env. Run either the script or cargo run, not both.
Nginx is not needed. When another program uses port 8080, set INTERNAL_PORT.
curl https://dash.dev.localhost/api/healthNo worker or project found for request means the API worker name does not match
API_WORKER_NAME (default openworkers-api). Dashboard hosts are configured
with DASHBOARD_HOSTS; worker suffixes use WORKER_DOMAINS.
Iterate on the API. The SvelteKit app runs as a Bun process against Postgate:
cd openworkers-api
cat > .env << 'EOF'
APP_URL=https://dash.dev.localhost
POSTGATE_URL=http://localhost:6080
POSTGATE_TOKEN=pg_xxx
POSTGATE_SYSTEM_TOKEN_SECRET=dev-system-token-secret-at-least-32-chars
JWT_ACCESS_SECRET=dev-access-secret-at-least-32-chars-long
JWT_REFRESH_SECRET=dev-refresh-secret-at-least-32-chars-long
EOF
bun install
bun devpg_xxx is the token from step 5. Vite listens on 5173, or PORT when set;
talk to it directly there. Without a
DATABASE binding the app falls back to the token, and a missing one gives No database client available: neither DATABASE binding nor POSTGATE_TOKEN configured.
Test what you ship. Redeploy the worker and use the runner's public address:
cd openworkers-api
bun run build && ow infra workers upload openworkers-api ./buildpackage.json wraps this as deploy:local, deploy:dev and deploy:main, one
per alias. Bindings, the runner's fetch routing, and the isolate's environment
only behave like production in this mode, so anything touching them has to be
checked here before it counts as working.
| Service | Port | Set by |
|---|---|---|
| PostgreSQL | 5432 | postgres |
| Postgate | 6080 | POSTGATE_PORT; the binary alone defaults to 3000 |
API (bun dev) |
5173 | vite; PORT overrides |
| Runner internal | 8080 | Loopback only; fixed |
| Runner HTTP | 8081 | HTTP_ADDR |
| Runner HTTPS | 8443 | HTTPS_ADDR; requires TLS files |
The Compose overlays publish HTTP/HTTPS on 80/443 (dev), and HTTPS on 443 (prod).
| Message | Cause |
|---|---|
unsafe use of new value "planetscale" of enum type |
Migration 28 through the CLI runner (step 3) |
Storage can only be configured for DB aliases |
setup-storage without the alias prefix (step 6) |
Platform storage not configured |
setup-storage never run for this alias (step 6) |
User not found |
The alias --user matches no users.username (step 4) |
/sign-in?error=github-not-configured |
GITHUB_CLIENT_ID unset in the API environment (step 7) |
No worker or project found for request |
X-Worker-Name names a worker that does not exist (step 9) |
No database client available |
Dev-loop API with neither binding nor POSTGATE_TOKEN (step 10) |
compose.yml runs PostgreSQL, private Postgate and one runner. The API
worker is uploaded with the CLI as in step 7. .env supplies configuration.
The default build uses the sibling openworkers-runner checkout. TLS is optional;
add -f compose.tls.yml to the command below when TLS paths are set in .env.
The production overlay also enables inbound-allowlist.txt; see the deployment
instructions before exposing the runner.
docker compose -f compose.yml -f compose.dev.yml up -d --build --remove-orphans
docker compose ps
docker compose logs -f openworkers-runner
./database.sh backup
./database.sh restore ~/backups/openworkers/openworkers-2026-01-10.dump
./database.sh psqlSee deployment before replacing an existing multi-runner stack.