Deployment
Run Rover as a container on a shared network and every agent that can reach it gets the same cache — a page fetched by one agent is a cache hit for the next. rover mcp --http is what makes this possible: a long-running HTTP server multiple clients call concurrently, in place of the one-process-per-agent stdio transport. See rover mcp --http for the flag and endpoint reference and [http] for the config block; this page covers running it as a container.
Compose
The repository ships two compose files, one per image. docker-compose.yml runs the default image; docker-compose.headless.yml runs the runtime-headless image with the flags Chrome needs, covered under SPA rendering. Both put Rover on an internal network with no host port published: agent containers reach it at http://rover:7683/mcp; nothing else on the host can. The file below is the default one.
# Rover on an internal network. No host port is published: agent containers
# reach it at http://rover:7683/mcp and nothing binds on the host.
services:
rover:
build: .
environment:
# Fails the whole `up` if unset, rather than starting an open instance.
ROVER_HTTP_TOKEN: "${ROVER_HTTP_TOKEN:?set ROVER_HTTP_TOKEN in .env — generate with: openssl rand -hex 32}"
ROVER_CONFIG: /config/rover.toml
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
RUST_LOG: info
volumes:
- rover-data:/data
- ./rover.toml:/config/rover.toml:ro
networks: [agents]
# Bounded, not Docker's unbounded default: an unauthenticated caller on
# this network can drive hundreds of rejected requests/sec against
# /mcp, and without a cap that fills the host disk. See "Security
# limits" below.
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
volumes:
rover-data:
networks:
agents:
Two required steps before the first up — compose does not do either for you, and skipping the first fails silently rather than loudly:
-
Create
rover.tomlfrom the example.docker-compose.ymlbind-mounts./rover.toml, which the repository does not ship (it's your local, possibly environment-specific config). If that path doesn't exist on the host, Docker creates a directory there instead of failing, Rover finds an unreadable, non-file config path, and silently falls back to built-in defaults — your[http]and[ssrf]settings never apply and nothing tells you why.cp rover.toml.example rover.toml -
Generate the token, or compose refuses to start:
echo "ROVER_HTTP_TOKEN=$(openssl rand -hex 32)" >> .env
The image's own CMD already runs rover mcp --http --bind 0.0.0.0:7683, so the compose file doesn't repeat it — build: . plus the environment above is the whole setup.
The volume
/data holds the SQLite database (cache entries, task state, the event log), downloaded tokenizers, and any extracted output (image downloads, table CSVs). It has to survive restarts: an empty volume means a cold cache and a fresh tokenizer download on every docker compose up.
Docker seeds a named volume's content and ownership from the image only the first time that volume is created. Two consequences:
- A
rover-datavolume created by an image built before this ownership fix stays root-owned. Upgrading onto it fails withEACCESthe moment Rover tries to openrover.dbas uid10001. Rundocker volume rm rover-dataonce and let the nextuprecreate it from the current image. - Bind mounts are never seeded and always keep host ownership, regardless of image. If you bind-mount
/datainstead of using a named volume,chown 10001:10001it on the host first.
Client configuration
Point an MCP client at the container's /mcp path with the token in an Authorization header:
{
"mcpServers": {
"rover": {
"type": "http",
"url": "http://rover:7683/mcp",
"headers": { "Authorization": "Bearer ${ROVER_HTTP_TOKEN}" }
}
}
}
Server-side paths
tables.mode = "csv_file" and images.mode = "download" write files to /data on the server and put the absolute path in the returned Markdown — a path that means nothing to a caller in a different container. Both are refused over HTTP by default. Set [http] allow_server_paths = true only when the client mounts the same volume at the same path, so the path it receives actually resolves on its own filesystem.
First run needs outbound network
The first count_tokens call, or the first token-budgeted fetch, downloads a tokenizer from HuggingFace into /data/.cache/huggingface/hub. It happens once per volume — after that, the tokenizer is cached alongside everything else under /data and every later call is offline. If the container has no outbound network access, that first call fails.
Web search
Both container targets include the web-search Cargo feature. Unlike headless, it needs nothing at runtime — no browser, no model weights, no extra layer — so leaving it out of the image would mean a container deployment silently lacking a capability every other official distribution has.
It still needs a credential, which is an environment variable, never a file:
services:
rover:
environment:
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
Both shipped compose files already pass it through from your .env. Leave it unset and search reports search_not_configured while everything else works unchanged — the container starts, /readyz passes, and fetching is unaffected.
Two operational notes. Every search is a billable request to Brave, and so is every offset page; [search] requests_per_minute (default 60) paces the endpoint per process, so N replicas mean N times that ceiling — the same per-process caveat that applies to fetch rate limiting. And search results are never cached, so a shared container gives you no request-count savings on search the way it does on fetches.
rover doctor inside the container reports search status without spending a request:
docker compose exec rover doctor
See Web search for the full picture.
SPA rendering
The default image ships without Chromium: it is built without the headless Cargo feature, so headless.mode = "on" returns headless_feature_not_compiled and auto behaves like a plain fetch. See JavaScript & dynamic pages for the mode-by-mode behaviour.
A second build target adds it:
docker build --target runtime-headless -t rover:headless .
Or use the overlay, which sets the flags below for you:
docker compose -f docker-compose.headless.yml up
That image is about 1.2GB against the default's 83MB — both measured on arm64 — almost entirely Chromium and its dependencies.
It needs three run flags
Chrome's sandbox does not start under Docker's default seccomp profile, which blocks the user namespaces it relies on. Running it needs:
--security-opt seccomp=chrome.json— the profile is in the repository root--shm-size=1g— Chrome crashes on non-trivial pages with Docker's 64MB/dev/shm- a non-root user — already set in the image; do not override it
Without the profile, Rover refuses to render and tells you so. Chrome's own error output suggests --no-sandbox as a workaround. Do not take it: it removes the boundary that keeps a hostile page inside the renderer process, and Rover renders whatever page an agent asks for.
Rebuilding is how you get security fixes
The image installs Chromium from bookworm-security at build time and deliberately does not pin the version, so each rebuild picks up Debian's current backport. Nothing pushes a new image to you — Rover ships a Dockerfile, not a registry image — so the browser's patch level is a function of how often you rebuild. Chrome sandbox escapes are exploited in the wild; an image built once and left alone drifts to a known-vulnerable browser.
Request limits
A request body over 16 MiB is rejected. With a Content-Length header — every real MCP client sends one — that's a clean 413. A chunked request with no Content-Length is truncated at the same limit but comes back as 500, because the oversize collect error surfaces from the JSON-RPC layer rather than the body-limit layer. Memory use is bounded either way; only the status code differs.
Security limits
The bearer token is compared in constant time, but nothing rate-limits or locks out repeated failed attempts — a short or predictable ROVER_HTTP_TOKEN is brute-forceable at line rate. Generate a real one:
openssl rand -hex 32
That's a deliberate gap, not an oversight: the deployment target is a trusted container network, and closing it is only defensible if an operator can actually see abuse happening. Two things make that possible. Every rejection carries the caller's address (peer = <addr>) so you know who to look at, and the line itself is throttled to at most once per second with a suppressed count folded in — an unauthenticated caller hammering /mcp produces one log line a second, not one per request, so the log itself can't become the disk-filling attack (docker-compose.yml's logging: limits are the second half of that same mitigation). Neither of those slows an attacker down; they only make the attempt visible.
Treat the container network as the primary boundary, not the token — put Rover on a network only trusted callers can reach, the way the compose file above does, and let the token be a second layer rather than the only one.
Health
GET /healthz is liveness and returns the running Rover version; GET /readyz opens the cache database and returns 200 if it answers, 503 if it doesn't. Neither requires ROVER_HTTP_TOKEN, so an orchestrator can probe both with no credential configured.
/healthz/ and /readyz/ — trailing slash — are different, unrouted paths, not the same endpoint with looser matching. Once a token is set, every unrouted path returns 401 instead of 404, so a probe pointed at the wrong URL fails as an auth error rather than a connection error. Configure your orchestrator's probe against the exact path, no trailing slash.
Neither image defines a HEALTHCHECK, and neither compose file has a healthcheck: block. For the default image there's no choice: Docker's healthcheck: needs a command to run inside the container and distroless has no shell. runtime-headless is debian:bookworm-slim and does have one, so it could — it deliberately doesn't, because an in-container probe would be a second, differently-configured way to ask the same question. Probe /readyz from your orchestrator instead, the way it already reaches the container over the network.