OpenBrowse

Installation

Full installation of OpenBrowse on Debian or Ubuntu, including the virtual display behind the live view, configuration, running it as a service, and keeping it up to date.

The quick start on the getting started page is enough to try OpenBrowse. This page covers the full install: the system packages behind the live browser view, every configuration variable the application actually reads, running it as a service that survives reboots, and how updates arrive afterwards.

What you need

A Debian or Ubuntu machine with SSH access. OpenBrowse was built and benchmarked on a Raspberry Pi 5 with 16GB of RAM, and runs on any comparable VPS.

Budget roughly 2GB of RAM and one CPU core per concurrent session. Chromium itself accounts for 400 to 600MB of that; the rest covers the pages it loads, the virtual display it draws into, and the Python process. Concurrency is bounded by memory rather than by anything in the application.

You also need an API key from Anthropic, OpenAI, or both.

System packages

The live view works by running Chromium against a virtual X display and streaming it over VNC, so three separate pieces have to be present: the display (Xvfb), the VNC server (x11vnc), and the websocket bridge (websockify with the noVNC assets) that your browser connects to. Each session gets its own trio, allocated when the session starts and released when it ends.

sudo apt update && sudo apt upgrade -y

sudo apt install -y xvfb x11vnc novnc websockify
sudo apt install -y python3-venv python3-pip

Chromium needs its own set of shared libraries. Installing these up front avoids a launch failure that otherwise only appears when you start your first session:

sudo apt install -y \
  libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 libxdamage1 \
  libxrandr2 libgbm1 libpango-1.0-0 libasound2 libxshmfence1 libgtk-3-0

Install OpenBrowse

OpenBrowse is published to PyPI as openbrowse. Three installers are equally supported, and each puts the openbrowse command on your PATH:

# uv
uv tool install openbrowse

# pipx
pipx install openbrowse

# venv
source <path-to-venv>/bin/activate
pip install openbrowse

uv and pipx each keep the app in an isolated environment they manage for you, and both know how to upgrade it later. If you have neither, curl -LsSf https://astral.sh/uv/install.sh | sh installs uv.

The pip route needs a virtual environment, and the activation line above is not optional. Raspberry Pi OS and Debian mark the system Python as externally managed, so pip install outside a virtual environment is refused by the distribution rather than by us.

The rest of this page assumes only that openbrowse runs.

To work on OpenBrowse rather than only run it, clone the repository instead and run it out of the checkout. Everything below behaves the same, except that the checkout is its own home directory:

cd ~
git clone https://github.com/lujstn/openbrowse.git
cd openbrowse
uv sync

Where OpenBrowse keeps its files

One directory holds .env and everything under data/, and which one it is depends on how you installed:

How you installedHome directory
uv tool install, pipx, or pip~/.openbrowse
A git checkoutThe repository root, as before
Anything, with OPENBROWSE_HOME setWhatever that names

The distinction exists so that an installed copy never writes into its own site-packages, where the next upgrade would delete it. OPENBROWSE_HOME overrides both and is the one setting that cannot live in .env, because it decides which .env gets read: export it from your shell or set it in the systemd unit.

A command run under sudo still resolves to the invoking user's home rather than root's, because SUDO_USER is consulted before HOME is. So sudo openbrowse start reads the .env you wrote instead of creating a second, empty, root-owned copy of it beside your real one.

The browser binary

OpenBrowse does not drive the Chromium from apt. It drives a stealth-configured build supplied by CloakBrowser, the library that provides the patched binary, and that binary is downloaded the first time a session launches rather than during installation.

That download is around 200MB and takes several minutes on a Raspberry Pi, so nothing is left to discover it mid-session. The setup wizard fetches it as one of its steps, with progress on screen, and the server starts a fetch at boot if the binary is still missing. A session launch waits for whichever fetch is in flight to finish before starting Chromium, rather than racing it: on SD-card hardware a download and a browser launch competing for the same disk starve Chromium of I/O until its debug port misses the readiness window, which surfaces as a launch failure with no obvious cause.

For the same reason OpenBrowse sets CLOAKBROWSER_AUTO_UPDATE=false at startup, so the library never pulls a new build underneath a running launch. Updates to the binary happen at boot or during onboarding, deliberately, or not at all.

From a checkout you can also fetch it by hand:

uv run python -c "import cloakbrowser; print(cloakbrowser.ensure_binary())"

The command prints the path to the binary and exits.

Configuration

The simplest path is to start the server and let it configure itself. An unconfigured instance sends any visit to the dashboard on to a one-time setup wizard at /setup, which takes four steps and then writes .env for you:

StepWhat it does
Dashboard passwordThe password you will sign in with, at least 8 characters. The username is admin
Model providerAn Anthropic or OpenAI key, and optionally a CapSolver one. At least one of the two model providers is required; CapSolver does not count towards that
CapacityHow much of this machine OpenBrowse may have, which sets the concurrency limit and the lighter browser profile
API keyThe bearer token it generates for your v3 clients
Host tuningOffers the openbrowse tune command for this machine. Optional, and you tell the wizard whether you ran it
Browser downloadFetches the stealth Chromium build now, with progress, rather than leaving it to your first session
RestartApplies the configuration and hands you to the dashboard

Each provider key is checked against the provider itself before the wizard accepts it, so a typo is caught here rather than at your first session. A key the provider rejects blocks the step; a provider that cannot be reached from this machine is reported as exactly that, because an unreachable API says nothing about whether the key is good.

The wizard refuses to overwrite a non-empty .env, and once any credential exists it redirects to the dashboard instead, so a configured instance never exposes it. It needs JavaScript; without it, write .env by hand as below. Before it asks for anything it checks that this machine has the roughly 2GB of free disk the browser build needs and that Xvfb is installed, because both are cheaper to discover now than at your first session.

The redirect exists because no default password ships. There is deliberately never a working credential that you did not choose, which means a fresh install has nothing to check a password against and asking for one would pose a question nothing could answer. The v3 API is left out of this: a programmatic call to an unconfigured instance still fails closed with a 401 rather than being sent to an HTML wizard.

Configuration is read once at startup. The wizard adopts the credentials it just wrote into the running process, so the dashboard is usable the moment you finish, but the capacity settings are not adopted: the session pool and the browser flags are already built by then, and only a restart honestly applies them. That is why the wizard ends in one. Editing .env by hand always needs a restart.

To configure by hand instead, create .env in the home directory above. These are the variables OpenBrowse itself reads. The browser layer also honours the CLOAKBROWSER_* overrides its own library defines, and BROWSER_USE_ACTION_TIMEOUT_S is set by the application at startup rather than read from your environment:

VariableDescription
API_KEYBearer token authenticating API requests. Required; without it every API call returns 401
ANTHROPIC_API_KEYAnthropic key, for claude-* models
OPENAI_API_KEYOptional. OpenAI key, for gpt-* models
DEFAULT_MODELOptional. The model a request that names none runs on. Left unset, the default follows whichever provider key is configured, gpt-5.6-terra for OpenAI and claude-sonnet-5 for Anthropic. The v3 API and the dashboard's run form both follow it
CAPSOLVER_API_KEYOptional. CapSolver key for CAPTCHA solving. Without it the CAPTCHA tool is simply not registered
CAPTCHA_MAX_COST_USDOptional. Ceiling on what one run may spend on CAPTCHA solving in total, default 0.03. Once a run's solves have reached it, further solves are refused rather than attempted. Each solve's real cost is added to the session total
DASHBOARD_USEROptional. Dashboard username, default admin
DASHBOARD_PASSWORDOptional. Dashboard password. Defaults to API_KEY. The setup wizard requires at least 8 characters; a value you write here by hand is not measured
MAX_CONCURRENT_SESSIONSOptional. Concurrent sessions, default 1. Budget roughly 2GB RAM and one core each. Over the cap a session is accepted and queued rather than refused, and the create call returns straight away
KEEP_ALIVE_IDLE_TIMEOUTOptional. Seconds a finished keepAlive session waits, browser still open, for its next follow-up before closing itself. Default 600; 0 parks indefinitely, until the session is stopped or its display slot is claimed by a new one
CHROME_LIGHT_FLAGSOptional. Set to 1 to start every browser in the lighter profile described under sizing it for your machine. Default off
CLOUD_MAX_COST_FACTOROptional. Scales an incoming maxCostUsd to local cost, for callers whose budgets were priced for a hosted service. Above 0 and at most 1; 0.5 turns a $6 cap into $3. Default 1.0. An out-of-range value stops the server at startup with a clear error
ALLOW_INSECURE_NO_AUTHOptional. Set to 1 to run without any authentication. Development only; never expose an instance configured this way
UPDATE_CHECK_HOURSOptional. How often to ask PyPI whether a newer release exists, in hours. Default 6; 0 switches the background check off, and the dashboard's update badge with it

One more variable is read before .env is, so it has to come from the environment itself rather than from that file:

VariableDescription
OPENBROWSE_HOMEOptional. The directory holding .env and data/. Overrides both defaults above. Export it from your shell or set it in the systemd unit; a value written into .env can never take effect, and the dashboard's Settings page will not offer it as an editable row for that reason

Generate a strong API_KEY:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Treat .env as secret. So is everything under data/, which holds the SQLite database (data/browser_use.db), profile cookie jars (data/profiles/), and screenshots. Profile jars are live credentials for every site your profiles are logged into.

Verify

openbrowse serve

You should see:

2026-08-18 09:14:02,881 [INFO] openbrowse.main: Initializing database...
2026-08-18 09:14:03,140 [INFO] openbrowse.main: Server ready on 0.0.0.0:8420
INFO:     Application startup complete.

The timestamped lines are the application's own; the bare INFO: prefix is uvicorn's. Note that Server ready on reports the host and port from your configuration, so if you passed openbrowse serve --port this line still shows the configured default while the server listens on the port you asked for. --port 0 is honoured as a request for an ephemeral port rather than being read as "unset".

Check the API is answering:

curl http://<your-host>:8420/health
{ "status": "ok" }

/health is unauthenticated. /health/details requires the API key and additionally reports how many sessions are running right now. Then open http://<your-host>:8420/ for the dashboard, which signs in with DASHBOARD_USER and DASHBOARD_PASSWORD over HTTP Basic auth.

Run it as a service

Running under systemd means it restarts on failure and comes back after a reboot. One command writes the unit, enables it and starts it:

openbrowse start

It asks for your password, because writing into /etc/systemd/system and enabling a unit both need root, and it prints in plain words whether OpenBrowse will now come back on boot. openbrowse status, openbrowse restart and openbrowse stop manage it afterwards, and openbrowse stop --disable is the one that also stops it starting on boot. On a machine with no systemd, openbrowse start says so and runs in the foreground instead.

The generated unit runs as the user who invoked the command, reads the .env in that user's home directory, and starts the openbrowse launcher by the path it is on rather than by whatever that path resolves to, so an upgrade that repoints a versioned symlink does not leave the unit pointing at a directory the upgrade removed.

Follow the logs with journalctl -u openbrowse -f.

Keeping a unit you wrote yourself

openbrowse start writes a unit only when none exists. If one is already there and its ExecStart differs from what this copy would write, it says so and keeps yours, because a hand-written unit tends to carry hooks and limits the generator knows nothing about. Replacing it is then a deliberate act:

openbrowse start --reinstall-unit

The Tailscale integration is the usual reason to hand-write one. The generated unit has no tunnel hooks, so if you want the public tunnel to come up and down with the service, write this instead and leave it in place:

[Unit]
Description=OpenBrowse
After=network.target tailscaled.service
Wants=tailscaled.service

[Service]
Type=simple
User=<user>
WorkingDirectory=/home/<user>/.openbrowse
EnvironmentFile=/home/<user>/.openbrowse/.env
ExecStart=/home/<user>/.local/bin/openbrowse serve
ExecStartPost=+/usr/bin/tailscale funnel --bg 8420
ExecStopPost=-+/usr/bin/tailscale funnel --bg off
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

The two tailscale lines bring the public tunnel up and down with the service; see exposing it safely. On a source checkout, point WorkingDirectory, EnvironmentFile and ExecStart at the repository and its .venv instead. Either way the file belongs at /etc/systemd/system/openbrowse.service, and openbrowse start will then enable and start it without touching its contents.

If the server is killed mid-run, sessions that were executing are marked as errored with the note Interrupted by server restart at the next startup, so nothing is ever left claiming to run forever.

Updating

A background task asks PyPI every UPDATE_CHECK_HOURS whether a newer release exists. When one does, an Update available badge appears in the dashboard's navigation and the Settings page grows a software update card with a one-click Install and restart. The install is refused while any session is running, so an upgrade never interrupts a live browser.

The same thing from a shell:

openbrowse check-update
openbrowse update

The upgrade runs through whichever manager owns this copy, rather than through a single command that happens to work most of the time:

How it was installedWhat the upgrade runs
uv tool installuv tool upgrade openbrowse
pipx installpipx upgrade openbrowse
pip, in a virtual environmentpip install --upgrade openbrowse, or uv pip install --upgrade where the environment has no pip of its own
pip, into your user sitepip install --user --upgrade openbrowse, so it upgrades the copy that is actually running rather than the system one
A git checkoutgit pull --ff-only, then uv sync

A uv tool and a pipx app both live in a virtual environment, so both are recognised before the plain pip case; upgrading either with a bare pip would reach around the manager that owns it and leave its records describing a version that is no longer installed. Where the manager's own binary cannot be found, or a copy sits in the system Python where no upgrade is safe to run unattended, OpenBrowse names the install method and declines to invent a command rather than offering one that would fail on use.

That detection follows the package itself, not OPENBROWSE_HOME, so pointing the home directory at some other folder cannot aim a git pull at an unrelated repository. From the shell the restart is yours to do; from the dashboard it happens for you.

Run openbrowse tune --share <preset> once after upgrading. The sudoers entry that lets the dashboard tune the host and restart the service names host_tune.sh by its full path, and that path moves with the package, so an upgrade silently leaves those buttons unable to do anything. The Settings page says so when it detects it, and re-running the command rewrites the entry.

Sizing it for your machine

Concurrency that suits a Raspberry Pi strands a sixteen-core VPS, and the reverse thrashes the Pi, so the setup screen reads the machine before it asks you anything: cores, total and available memory, current load, whether the kernel exposes pressure stall information, whether this is a Raspberry Pi, whether systemd is in charge, whether the cgroup memory controller is available, whether the root filesystem is on an SD card, and whether the capacity drop-in below has already been written. The last three drive checklist rows rather than the slider. You then choose how much of the device OpenBrowse may have, and a slider recommends a session count bounded by what the hardware can hold.

ShareMeaningFraction of memory
All of itThe machine's only job90%
Most of itA machine that also does something else occasionally70%
A fair shareA machine with another real job40%

The same card is in Settings, so you can retune later without reinstalling, and a checklist alongside it shows which host-level steps are done and which remain. Probing degrades to a safe default anywhere it cannot read the machine, and nothing is recommended off a guess.

The slider stops at 8 sessions however large the machine is, and the recommendation drops by one on a host already running above 0.5 of load per core. Both are bounds on the recommendation, not on the software: MAX_CONCURRENT_SESSIONS set by hand in .env is not clamped, so a large VPS can go past 8 if you have the memory for it.

The host-level half

Two of those steps are outside the application: systemd does not know OpenBrowse should win a contended CPU, and on a Raspberry Pi the kernel ships with pressure stall information compiled out. openbrowse tune applies both in one idempotent command:

openbrowse tune --share most --dry-run   # show the plan
openbrowse tune --share most             # apply it

The work is done by a shell script bundled with the package, which needs root, so the command asks for your password rather than failing on a host where you are not already root.

It writes a systemd drop-in that weights the service's CPU share and caps its memory at the fraction you chose, appends the psi=1 boot flag where PSI is missing and this is a Pi, and adds a sudoers entry so the dashboard's one-click tuning and restart buttons work without a password. Every action prints what it did, repeats are skipped rather than rewritten, and --dry-run touches nothing. The memory cap applies from the next service restart; the PSI flag needs a reboot.

The drop-in is written under openbrowse.service.d, matching the unit name the application itself restarts by. If you run OpenBrowse under a differently-named unit, run the bundled script directly with --service <name> so the two agree; otherwise the dashboard will keep reporting the tuning as still to do.

That sudoers entry names the script by its full path, so it stops matching as soon as an upgrade moves the package. Re-run openbrowse tune --share <preset> after every upgrade. The Settings page notices the mismatch and names the command, but a machine tuned once and upgraded later will otherwise sit there with dead buttons and no error.

PSI matters because load average counts runnable processes, which mistakes a healthy burst of tabs for trouble and misses real starvation behind modest numbers. Where PSI is available, OpenBrowse instead measures the fraction of time runnable work sat waiting for a CPU, which is the thing that actually hurts. Hosts without it fall back to per-core load, and the telemetry names which signal it used.

The lighter browser profile

CHROME_LIGHT_FLAGS=1 starts every browser without a GPU process, with renderer processes capped at four, a 256MB JavaScript heap per renderer, Chromium's low-end device mode, and no background networking. On hardware with no real GPU, drawing into a virtual display, those are close to free, and they lower the memory floor of every session.

Setup pre-selects it where it earns its keep, meaning a Raspberry Pi, four cores or fewer, or 8GB of memory or less, and leaves it alone on a large host. It stays a checkbox either way.

Site isolation is deliberately left intact. Collapsing it would save more memory again, and it would also break reading pages inside cross-origin frames, which is precisely the case the benchmark task exists to prove. The one trade-off to watch is the JavaScript heap: if a single heavy site misbehaves only with the profile on, that 256MB ceiling is the first thing to suspect.

Next

Point a client at it from getting started, read the whole openbrowse command surface, understand what actually happens during a run in how OpenBrowse works, or read the v3 API reference.

On this page