SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host OpenBot AI coworkers on a VPS

Self-host OpenBot so each AI coworker gets its own container and browser. How the gateway decides every action, and what that costs you in RAM.

What you get when you self-host OpenBot AI coworkers

You self-host OpenBot AI coworkers by running one gateway server plus one container per bot on hardware you control. Each bot container carries its own Chromium browser and its own workspace volume, with a browser profile that persists between sessions. Every action a bot takes on a computer, a file, an MCP (model context protocol) server or a UI component passes through that gateway, which checks it against a policy before it happens and records it after.

OpenBot is published by CopilotKit under the MIT license at github.com/CopilotKit/openbot. The first tagged release, v0.0.1, was published on 17 August 2026, and the project describes itself as alpha and under active development. Treat it as a serious design with early edges.

The interesting part of that architecture is also the expensive part. A browser for every agent is the memory cost most people forget to plan for, so sizing comes before the install here.

How the gateway decides every action

The API server on port 3001 is the only path to a bot's computer. Before a browser action runs, the gateway resolves the target from a page snapshot, evaluates CEL (common expression language) policy rules against the context, writes an audit row holding the decision, and only then calls the container. If execution fails after that, it writes a second row. The docs state the boundary plainly: the computer does not decide policy, the server gateway is the action boundary.

Policy is deny-by-default, and deny rules are evaluated before allow rules. The failure direction matters more than the rule syntax. A missing policy permits nothing, and a broken rule fails toward blocking whether it is a deny rule or an allow rule, so a mistake in your policy costs you a stuck bot rather than a bot loose on your accounts.

The audit trail lives in PostgreSQL, so it survives a restart. Control handovers are recorded as computer.help_requested, computer.control_taken and computer.control_released, which is how you see a bot ask for a human and how you see the human give control back. Secrets are recorded as character counts, never as values. File operations record the path and the size, never the contents. If you want the same control boundary without a browser behind it, gating AI agent actions behind approvals covers that narrower case.

What one bot costs in RAM and disk

The project publishes measured figures for a single Bot on arm64. Those are the only sizing numbers OpenBot ships, and they describe one bot on one architecture, so read them as a starting point rather than a capacity plan.

ChartOpenBot published resource figures, one Bot on arm64 (August 2026)
The data behind this chart
[
  {
    "label": "Measured, one Bot",
    "memory_gb": 0.55,
    "disk_gb": 5.3,
    "vcpu": 0.06
  },
  {
    "label": "Documented minimum",
    "memory_gb": 2,
    "disk_gb": 8,
    "vcpu": 1
  },
  {
    "label": "Documented recommended",
    "memory_gb": 4,
    "disk_gb": 10,
    "vcpu": 2
  }
]

Peak memory was measured at 0.55 GB for one Bot, while the documented minimum is 2 GB and the recommendation is 4 GB. The gap between the measurement and the minimum is room for Chromium to grow under load, because a browser's memory use tracks the pages it has open and not the process at rest. Idle CPU is close to nothing, at 0.06 of a core at the top of the measured range, so CPU is not what you are buying. Disk is. The image alone is 5.3 GB, against a recommended volume of 10 GB, and it is that large because it ships Playwright's Firefox and WebKit binaries next to Chromium.

None of that tells you what several bots cost together, and the project publishes no figure for that. Measure your own. Start one bot, give it a real task with a page open, and watch the container while it works.

docker stats --no-stream
free -m

Take the MEM USAGE column for the bot's container as your per-bot figure, add the gateway and PostgreSQL on top, then multiply the per-bot figure by the number of bots you expect to exist at the same time. An idle bot still holds a browser process, so the multiplier applies to bots that exist and not only to bots that are busy. The arithmetic is the same one used for sizing RAM and CPU for a coding agent VPS, and the browser side of it is covered in running a headless browser for agents on a VPS.

One Chromium detail affects small plans. OpenBot launches Chromium with --disable-dev-shm-usage, so the browser writes to /tmp instead of /dev/shm. That avoids the crash you get on hosts with a small /dev/shm, and it moves the pressure onto your root filesystem, which is one more reason the recommended disk is larger than the image.

How do you self-host OpenBot on a VPS?

You need Docker, Bun 1.3 or newer, a CopilotKit Intelligence project, and a model API key. The development docs also expect lsof, python3 and curl on the box. Clone a tagged release rather than main, because main on an alpha project changes without warning.

git clone --branch v0.0.1 https://github.com/CopilotKit/openbot.git
cd openbot
cp .env.example .env

Provision the Intelligence project. These three commands write the runtime key and the license token into your environment file.

npx --yes copilotkit@latest login
npx --yes copilotkit@latest project select
npx --yes copilotkit@latest license --write

Generate the key that encrypts stored credentials, and put the output in .env as KEY_ENCRYPTION_KEY. Add your OPENAI_API_KEY in the same file, or set BOT_PROVIDER to anthropic or google with the matching key.

openssl rand -base64 32

Then install and start.

bun install
bash scripts/start.sh

scripts/start.sh brings up the Docker services, runs the database migrations, starts the server and the app, and checks their health. When it finishes, the app answers on port 3010 and the API on port 3001. The script reports port conflicts and leaves an already-running matching service alone, so running it twice is safe.

Check it from the server itself before you expose anything.

curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3010
ss -ltnp | grep -E ':(3010|3001|4100|4500|5432)'

A 200 from the first command means the app is serving. The second command shows which addresses those ports are bound to, and that is the answer that matters on a VPS. A line reading 127.0.0.1:3001 is private to the box. A line reading 0.0.0.0:3001 means anyone who can route to the server can reach it.

The single container image

The deployment docs also ship a single image holding the app, the API and Chromium, served on port 3001.

docker build -t openbot .
docker run -p 127.0.0.1:3001:3001 --env-file .env \
  -e EMBEDDED_POSTGRES=on -v openbot-data:/var/lib/postgresql/data openbot

EMBEDDED_POSTGRES=on runs PostgreSQL inside the container and applies migrations at startup. The named volume keeps the audit history across a redeploy, and without it every rebuild throws that history away. If you point DATABASE_URL at a managed database instead, the vector extension has to be enabled on it. Managed services such as RDS, Cloud SQL and Azure Database support the extension, and none of them enable it for you, so a migration against a fresh managed database fails because the vector column type does not exist yet.

Run migrations as a release step when the database is external.

docker run --rm --env-file .env openbot \
  sh -c "cd /app/server && bun x drizzle-kit migrate --config=drizzle.config.ts"

That image deliberately leaves the browser port unpublished. It also leaves out the supervisor, because the supervisor needs the Docker socket, which serverless platforms do not offer. Without the supervisor, every bot shares one browser and therefore one set of logins, which removes the isolation that made per-bot containers worth running. If separate logins per bot is why you are here, run the compose stack with COMPUTER_SUPERVISOR_URL and SUPERVISOR_TOKEN set, on a host where you accept the trade. A process that can talk to the Docker socket can start a privileged container, so it is root on the host in practice. That is a good reason to keep OpenBot on a machine of its own, in the same spirit as giving coding agents a disposable VM.

Why OPENBOT_SINGLE_USER is a laptop setting

.env.example ships with OPENBOT_SINGLE_USER=true. That setting admits every request as one administrator and skips sign-in entirely. On a laptop it is a convenience, because the only client that can reach the port is you. On a VPS it means the first person who reaches port 3010 becomes an administrator of a system that stores encrypted credentials and drives a browser already signed into your accounts.

There are two honest ways to run it. Keep OPENBOT_SINGLE_USER=true, bind every port to 127.0.0.1, and reach the app over an SSH tunnel or a private network interface only.

ssh -N -L 3010:127.0.0.1:3010 -L 3001:127.0.0.1:3001 you@your-vps

The app is then at http://localhost:3010 in your own browser, which counts as a secure context, so sign-in cookies and the browser features the live screen needs both work. The other way is to turn single-user mode off and configure a real identity provider. Google, Microsoft Entra, Okta, SAML and OIDC are supported. Any provider also needs BETTER_AUTH_SECRET at 32 characters or more, BETTER_AUTH_URL set to the public API base URL for OAuth callbacks, INITIAL_ADMIN_EMAILS, and TRUSTED_ORIGINS. Provider credentials must be complete, because a half-configured provider stops startup instead of falling back to open access.

If the app is reachable on a public name, put TLS (transport layer security) in front of it. A page served over plain http:// on anything but localhost is not a secure context, so cookies marked Secure are not stored and sign-in fails in a way that looks like a bug in OpenBot.

Firewall the lower-level ports

OpenBot's own security note says the lower-level service endpoints are protected by tokens, that you should keep them private, and that you should not use them to bypass the gateway. Tokens are the second protection. The first is that the port cannot be reached at all.

The agent-computer listens on 4100 and requires COMPUTER_TOKEN. The bot endpoints listen on 4200 and 4201. The supervisor listens on 4500 on the host and 4300 inside its container. PostgreSQL listens on 5432. None of those belong on a public interface, and on a single-user deployment neither do the app and the API.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verbose

There is a trap here that catches people who assume the firewall is enough. Publishing a container port with -p 3001:3001 makes Docker install a DNAT rule, so that traffic is handled in the FORWARD path and never passes through the INPUT chain that ufw's default deny governs. The port stays open while ufw status still prints Status: active. Bind the published port to loopback in the mapping itself, as -p 127.0.0.1:3001:3001, or set the host address in your compose file. Verify with ss -ltnp, not with ufw status.

OpenBot is not an offline stack

State this before you plan the deployment. OpenBot depends on a CopilotKit Intelligence project, which holds durable threads and conversation memory outside your box. The server validates INTELLIGENCE_API_URL, INTELLIGENCE_GATEWAY_WS_URL, INTELLIGENCE_API_KEY and COPILOTKIT_LICENSE_TOKEN at startup, and all four must be present together or startup fails. A free plan is available as of August 2026, and Intelligence is itself self-hostable, so a fully local deployment is possible with more work than the quickstart shows.

The model is the second external dependency. Nothing ships in the box. BOT_PROVIDER accepts openai, anthropic or google, and OPENAI_BASE_URL points the OpenAI path at any compatible endpoint, which is where running Ollama on a VPS to self-host an LLM fits if you want the tokens to stay on your own hardware. Browser control asks a lot of a model, so test a local model on a real task before you commit to it.

Run one replica, for now

The gateway caches page snapshots in the server process memory. With two replicas, a snapshot taken by one process is invisible to the other, so actions fail intermittently with element-not-found errors that look random. The deployment docs are explicit: run a single replica, and pin your platform's maximum instance count to 1. That limit ends when snapshot caching moves to the database. Until then you scale OpenBot by making the box bigger, not by adding boxes. Isolation between bots still comes from the per-bot containers, in the same way self-hosted agent sandboxes keep one agent's mistakes away from the others.

Failure modes and what you will see

Startup exits immediately after you fill in .env. The server validates configuration before it serves anything. A partial Intelligence block, a missing KEY_ENCRYPTION_KEY, or an OAuth provider with a client ID and no secret each stop startup rather than degrade quietly. Read the first error, fix that one field, start again.

Migrations fail on a managed database. The vector extension is not enabled by default, so the migration hits a column type PostgreSQL does not know. Connect as a superuser, run CREATE EXTENSION vector;, then rerun the migration step.

The app loads but sign-in never sticks. You are serving over plain http:// on a public address, which is not a secure context, so the Secure cookie is discarded. Put TLS in front, or use the SSH tunnel so the browser sees localhost.

Bots share logins you expected to be separate. The supervisor is not running, so there is no per-bot computer and every bot uses the shared browser. Confirm COMPUTER_SUPERVISOR_URL is set and that the supervisor can reach the Docker socket.

A bot stops and asks for help. That is the design working. The audit trail records computer.help_requested, you take control on the live screen, and the handover is recorded on both sides.

FAQ

Is OPENBOT_SINGLE_USER safe to leave on for a VPS deployment?

Only when the gateway cannot be reached from the internet. OPENBOT_SINGLE_USER=true admits every request as one administrator with no sign-in, so anyone who can open the port owns the deployment, its stored credentials and its signed-in browser. It is acceptable when every port is bound to 127.0.0.1 and you reach the app through an SSH tunnel or a private network interface. On a public interface, turn it off and configure Google, Microsoft Entra, Okta or OIDC together with BETTER_AUTH_SECRET, BETTER_AUTH_URL, INITIAL_ADMIN_EMAILS and TRUSTED_ORIGINS.

How much RAM does one OpenBot bot need?

The project's published figures for a single Bot on arm64 put peak memory at 0.55 GB, with 2 GB as the documented minimum and 4 GB recommended. There is no published figure for several bots at once, because each one holds a Chromium of its own. Run one bot on a real task, read that container's memory in docker stats, add the gateway and the database, then multiply by the number of bots you expect to exist at the same time.

Do I need a CopilotKit account to self-host OpenBot?

Yes. OpenBot depends on a CopilotKit Intelligence project for durable threads and memory, and the server refuses to start unless the Intelligence API URL, the gateway WebSocket URL, the API key and the license token are all set. A free plan is available as of August 2026, and Intelligence can be self-hosted, so the hosted dependency is removable with extra work. You also supply your own model API key, because no model ships with OpenBot.

Why does each bot get its own browser instead of sharing one?

Because a browser profile is an identity. A shared browser means shared cookies and shared sessions, so one bot signed into an account is every bot signed into that account. Per-bot containers give each coworker its own profile and its own logins. The cost is memory, since a Chromium per bot is the largest single item in the sizing.

Which OpenBot ports should be open on the firewall?

None of the lower-level ones. The agent-computer on 4100, the bot endpoints on 4200 and 4201, the supervisor on 4500 and PostgreSQL on 5432 all stay private. The project protects them with tokens and asks that you keep them unreachable anyway. Publish only what a person needs to open, and remember that a container port published with -p 3001:3001 is reachable regardless of a ufw default-deny rule, because Docker's DNAT rule puts that traffic in the FORWARD path instead of INPUT.