Deploy
This page shows how to run OpenBot in production. You build a Docker image, prepare the database, set up the platform, and start the server behind a reverse proxy. For every setting, see Configuration.
What you need
- A Linux server with Docker and Docker Compose.
- A domain name for OpenBot, such as
api.example.com, and a TLS certificate for it. - An OpenID Connect provider for the platform, such as
https://id.example.com. Register a client with the redirect URIhttps://api.example.com/auth/callback/oidc. You can skip it when people sign in only through the SSO connections of organizations. - A model service, if you want the Agent to do work. You add it through the API after setup. See Models.
Build the images
All Docker files are in deploy/docker/. One Dockerfile makes both images. Each image is a build target (a named last stage in the file):
| Target | Image | What it runs |
|---|---|---|
openbot |
openbot |
OpenBot and its migration program. |
protocol-test |
openbot-protocol-test |
The test service for the App Protocol. See Protocol test. |
The file docker-bake.hcl tells Docker Bake how to build them. Run this from the root of the repository:
export OPENBOT_RELEASE=$(git rev-parse HEAD)TAG=$OPENBOT_RELEASE docker buildx bake -f deploy/docker/docker-bake.hcl --loadThis builds openbot:$OPENBOT_RELEASE and openbot-protocol-test:$OPENBOT_RELEASE for linux/amd64. --load puts them in your local Docker. Without TAG, the tag is local. To build only one image, add its target name, for example openbot.
On a computer that is not x86_64, Docker needs QEMU (a program that runs code for another processor) to build the images. Install it once with docker run --privileged --rm tonistiigi/binfmt --install amd64.
Both images start from distroless, with the same Node.js version as development. Distroless images have no shell and no package manager, so you cannot docker exec a shell in them. They run as the user nonroot (65532).
The OpenBot image has Node.js, the bundled server, the bundled migration and setup programs, and the database migrations. It has no build tools and no secrets.
| Path in the image | What it is |
|---|---|
/app/dist/app/Main.mjs |
The server. The image runs it by default. |
/app/dist/app/Migrate.mjs |
The migration program. It prepares the database. |
/app/dist/app/Setup.mjs |
The setup program. It writes the first platform settings. |
/app/migrations |
The database migrations. |
The server in the image listens on 0.0.0.0:3000. The image has a health check that calls /health every five seconds.
Secrets as files
OpenBot reads each setting from an environment variable first. When the variable is not set, OpenBot reads the file /run/secrets/<NAME> instead, for example /run/secrets/OPENBOT_SECRET_KEYS. This is where Docker and Compose put secrets. An empty file counts as not set. See Configuration.
Environment variables show in docker inspect. Files in /run/secrets do not. Compose uses files for all secrets.
Run with Docker
Put your settings in a file, for example openbot.env. It needs only OPENBOT_DATABASE_URL and OPENBOT_SECRET_KEYS. Start from deploy/docker/production.env.example. docker run does not fill in ${POSTGRES_PASSWORD}, so write the full OPENBOT_DATABASE_URL yourself.
Make the secret keys once:
echo "OPENBOT_SECRET_KEYS=k1:$(openssl rand -hex 32)" >> openbot.envSee Secret keys.
Prepare the database
The migration program only needs OPENBOT_DATABASE_URL. Run it before you start the server, and again before each new release:
docker run --rm --env-file openbot.env \ openbot:$OPENBOT_RELEASE /app/dist/app/Migrate.mjsIt is safe to run it more than once. It only applies the migrations that the database does not have yet. Migrations only go forward. There is no way to undo one, so make a backup first.
Set up the platform
Run the setup program once, on a new database:
docker run --rm --env-file openbot.env openbot:$OPENBOT_RELEASE \ /app/dist/app/Setup.mjs \ --public-origin=https://api.example.com \ --oidc-issuer=https://id.example.com \ --oidc-client-id=openbot \It writes the public origin, the platform provider, and the first platform admins to the database. Give a client secret with -e OPENBOT_SETUP_OIDC_CLIENT_SECRET=..., not on the command line. See Platform for every flag.
You can also run setup after the server starts. Until then, every route except /health answers 503 with SetupRequired.
Start the server
docker run -d --name openbot --env-file openbot.env \ -p 127.0.0.1:8080:3000 openbot:$OPENBOT_RELEASEWhen the server starts, it checks that the database has all tables. If not, it stops and you see an error in the log.
Check it
curl http://127.0.0.1:8080/health{ "status": "ok" }/health only shows that the HTTP server can answer. It does not check the database, the setup, or the model.
Alice can now sign in. She is a platform admin. Next, add a model provider. See Models.
Without Docker
You can also run the programs with Node.js on the server:
pnpm install --frozen-lockfilepnpm --filter-prod @openbot-build/openbot... buildpnpm --filter @openbot-build/openbot migratepnpm --filter @openbot-build/openbot setup --public-origin=https://api.example.com [email protected]pnpm --filter @openbot-build/openbot startThe package @openbot-build/openbot also has the commands openbot-migrate, openbot-setup and openbot.
Run with Compose
The file deploy/docker/compose.production.yaml runs these services:
| Service | Image | Port on the host |
|---|---|---|
postgres |
postgres:18-alpine, data in the volume postgres |
none |
migrate |
openbot:$OPENBOT_RELEASE |
none |
openbot |
openbot:$OPENBOT_RELEASE |
127.0.0.1:8080, or OPENBOT_PUBLISHED_PORT |
protocol-test |
openbot-protocol-test:$OPENBOT_RELEASE |
127.0.0.1:3001 |
migrate runs the migration program once, after PostgreSQL is healthy. openbot starts only when migrate succeeds. So up always migrates the database first.
The protocol-test service is the public test service for the App Protocol. See Protocol test.
The file needs PROTOCOL_TEST_PUBLIC_ORIGIN even if you only use OpenBot.
Compose gives the secrets to the containers as files in /run/secrets, not as environment variables. These are POSTGRES_PASSWORD, OPENBOT_DATABASE_URL, and OPENBOT_SECRET_KEYS. Compose takes their values from .env.production.
The openbot service gets only OPENBOT_DATABASE_URL and OPENBOT_SECRET_KEYS. It has no other environment variables, because every other setting lives in the database. The migrate service gets only OPENBOT_DATABASE_URL.
In the template, OPENBOT_DATABASE_URL uses ${POSTGRES_PASSWORD}, so you set the password only once.
Prepare the settings
cp deploy/docker/production.env.example .env.productionopenssl rand -hex 32 # use the output for POSTGRES_PASSWORDecho "k1:$(openssl rand -hex 32)" # use the output for OPENBOT_SECRET_KEYSFill in the values in .env.production.
Build both images
export OPENBOT_RELEASE=$(git rev-parse HEAD)TAG=$OPENBOT_RELEASE docker buildx bake -f deploy/docker/docker-bake.hcl --loadCheck the settings
docker compose --env-file .env.production -f deploy/docker/compose.production.yaml config --quietThis shows an error when a required setting is missing. Without --quiet, it prints the full setup with all values filled in.
Start everything
docker compose --env-file .env.production -f deploy/docker/compose.production.yaml up -d --wait--wait returns when all services are healthy. The images check their health every five seconds. When a service process stops, Compose starts it again.
Set up the platform
Run the setup program once, after the first up:
docker compose --env-file .env.production -f deploy/docker/compose.production.yaml \ run --rm openbot /app/dist/app/Setup.mjs \ --public-origin=https://api.example.com \ --oidc-issuer=https://id.example.com \ --oidc-client-id=openbot \Until you do this, every route except /health answers 503 with SetupRequired. See Platform.
To release a new version, build new images, set the new OPENBOT_RELEASE, back up the database, and run up -d --wait again. migrate runs again before the new OpenBot starts.
Never delete the postgres volume. It holds all your data.
Many instances
You can run many OpenBot instances behind one load balancer. Give every instance:
- the same
OPENBOT_DATABASE_URL, - the same
OPENBOT_SECRET_KEYS.
Nothing else is needed. All shared settings live in the database. When an admin changes them, every instance uses the change at once. See Configuration. Run the migration program and the setup program only once, not on each instance.
TLS and the reverse proxy
In production, a reverse proxy handles HTTPS and sends the traffic to OpenBot on 127.0.0.1:8080.
Leave OPENBOT_TLS_CERT and OPENBOT_TLS_KEY empty.
The proxy must:
- send the
Origin,Cookie, andAuthorizationheaders to OpenBot without changes, - not buffer responses, because Turns can stream their output as server-sent events (SSE),
- allow long responses.
GET /v1/sessions/:id/waitcan wait for up to 30 seconds, and a stream lasts as long as its Turn.
server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/ssl/api.example.com.pem; ssl_certificate_key /etc/ssl/api.example.com.key;
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 1h; }}api.example.com { reverse_proxy 127.0.0.1:8080 { flush_interval -1 }}Your own client on the same origin
A browser client on the same origin as OpenBot can use the sign-in cookie. It needs no user token. To do this, your proxy sends some paths to OpenBot and all other paths to your client.
| Path | Send to | What it is |
|---|---|---|
/auth/* |
OpenBot | Sign-in, sign-out, and the App authorization callback. |
/v1/* |
OpenBot | The API. |
/health |
OpenBot | The health check. |
/openapi.json |
OpenBot | The OpenAPI document. |
/docs |
OpenBot | The interactive API reference. Optional. |
| everything else | Your client | Your pages, including /. |
When you mount your client at /, the service document of GET / is no longer public. That is fine. Clients can use the paths above directly.
After sign-in, OpenBot sends the browser back to a path on your client. The default is /. Your client must also handle these return addresses:
/?authorization=<flowId>after an App authorization. CallcompleteAppAuthorizationnext. See Apps./?developerTest=<testId>after a developer test authorization. See Developer tools.
A return path can be any path on the same origin, except paths under /auth/, /v1/, and /development/.
Backups
Back up two things, and keep them together:
- The database. It holds the platform and organization settings, accounts, organizations and their members, user tokens, model providers, SSO connections, the audit log, Sessions and their Timelines, and App connections.
OPENBOT_SECRET_KEYS. The keys decrypt the secrets in the database: App secrets, model provider keys, SSO client secrets, and the sign-in secret. A database backup without the keys cannot restore them.
With Compose, make a database backup like this:
docker compose --env-file .env.production -f deploy/docker/compose.production.yaml \ exec -T postgres pg_dump -U openbot -Fc openbot > openbot-$(date +%F).dumpTo restore it, first stop OpenBot with docker compose --env-file .env.production -f deploy/docker/compose.production.yaml stop openbot. Then run pg_restore:
docker compose --env-file .env.production -f deploy/docker/compose.production.yaml \ exec -T postgres pg_restore -U openbot -d openbot --clean < openbot-2026-10-20.dumpGitHub Actions
The workflow .github/workflows/ci.yml has two jobs. The second job runs only when the first one passes.
| Job | What it does |
|---|---|
check |
Runs pnpm build, pnpm typecheck, and pnpm -r test in devenv shell. |
images |
Builds, checks, scans, and publishes the Docker images. |
Both jobs run on ubuntu-latest. They install Nix with the Determinate Nix installer, and then devenv 2.4.0. Magic Nix Cache keeps Nix build results in the GitHub Actions cache, so later runs do not build them again. The jobs also download prebuilt devenv packages from devenv.cachix.org. The check job keeps the pnpm store in a cache.
The images job does these steps:
- hadolint and
docker buildx bake lintcheck theDockerfile. The second one runs the Docker build checks. - Docker Bake builds both images. Build layers stay in the GitHub Actions cache, with one cache for each image, so later runs build only what changed.
deploy/docker/check.shchecks the images. See Check the images.- dive checks that the image layers do not waste space.
- Trivy scans both images. The job fails when an image has a known
HIGHorCRITICALvulnerability that has a fix. Trivy does not see the Node.js version, because Node.js is copied into the image and not installed as a package. - The job saves both images as the
images-<commit-sha>artifact for seven days. - For pushes, the job publishes the images. It uses the build cache, so it does not build again.
The workflow runs for pull requests, pushes to main, and tags like v1.2.3. It publishes images only for pushes:
| Event | Published tags |
|---|---|
A push to main |
<commit-sha> and latest |
A tag like v1.2.3 |
<commit-sha> and v1.2.3 |
The images go to the GitHub container registry, at ghcr.io/openbot-build/openbot and ghcr.io/openbot-build/openbot/protocol-test. The workflow does not deploy OpenBot. To use a published image with Compose, pull it and tag it as openbot:<tag>.
Publish the docs site
The workflow .github/workflows/docs.yml publishes the docs site to GitHub Pages at openbot.build. It runs for each push to main. You can also start it by hand from the Actions tab.
| Job | What it does |
|---|---|
build |
Runs pnpm --filter "@openbot-build/docs..." build in devenv shell. This builds the docs and the packages they need. |
deploy |
Sends packages/docs/dist to GitHub Pages. |
The build job sets DOCS_URL to https://openbot.build. Astro uses it for full page links, such as the sitemap. Only one deploy runs at a time.
The repository settings must use GitHub Actions as the Pages source, with openbot.build as the custom domain. The DNS for openbot.build must point to GitHub Pages. See Manage a custom domain.
Check the images
deploy/docker/check.sh starts compose.production.yaml with test settings. The extra file compose.check.yaml removes the host ports and adds two services. The setup service runs the setup program with test values after migrate, and before openbot starts. The check service runs the checks. check.env has the test settings. None of them are real secrets.
The check service runs check.mjs inside the Compose network, after OpenBot and the protocol test service are healthy. It checks that:
/v1/sessionsreturns401with no key and with a bad key,/openapi.jsonworks,- the protocol test Catalog has the right public origin,
- MCP discovery on the protocol test service works.
So the check also proves that migrations, the setup program, secret files, and health checks work in the real production setup. At the end, the script removes the containers and the test database.
Run the same checks on your computer:
devenv shell -- pnpm imagesdevenv shell -- pnpm images:checkdevenv shell -- hadolint deploy/docker/Dockerfiledevenv shell -- dive --ci --source docker openbot:localdevenv shell -- trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed openbot:local