SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-20

Self-host HarnessRouter: one agent API

Run Codex, Claude Code and Hermes behind one self-hosted API. The exact Docker deploy, the loopback bind, the default login you must change, and TLS access.

What HarnessRouter removes

You self-host HarnessRouter Community Edition to put one API in front of several agent harnesses on a server you own. An agent harness is the command line program that drives a model in a loop: it keeps a session, edits files, runs commands, and streams progress back to whatever asked for the work. Codex, Claude Code and Hermes each do that job, and each arrives with its own install, its own credential format and its own idea of what a session is. HarnessRouter runs all of them inside one container and puts a single HTTP endpoint, a single login and a single secret store in front.

That is the whole idea, and the cost is worth saying out loud. You are adding a container, a login, a volume and an upgrade path to your server so that several moving parts become one. If you run exactly one harness today, this is a worse setup than installing that harness directly. That trade is the last section here, so read it before you deploy.

Everything below was checked against image tag 0.5.5, pulled on 19 August 2026. The project publishes new tags most days, so check the tag you actually run instead of trusting this page in a month. The commands come from the project README at github.com/HarnessRouter/harnessrouter.

What the Unified Harness Protocol actually is

HarnessRouter implements the Unified Harness Protocol (UHP), published at unifiedharnessprotocol.org. UHP describes how a product starts a task on a harness, follows the task while it runs, manages sessions and files, and reports failure. The spec is versioned by date. The version live as of 19 August 2026 is dated 2026-08-11, and the site calls it a draft standard, "stable enough to build on, versioned so it can change safely".

Read the phrase "open standard" carefully here. The same company writes the specification, the reference implementation and the 52-check conformance suite that decides who conforms. That is ordinary for a protocol this young, and the Apache-2.0 licence means you can fork any part of it. It also means UHP is not yet a multi-vendor standard. Treat it as an emerging protocol: useful, moving, and something your own code should be able to stop using without a rewrite.

What you need before you start

Docker and roughly 4 GB of free disk. You also need an API key from a model provider you already pay for. The image pull is about 700 MB, and the rest of the disk goes to the agent CLIs and the workspaces they write in. There is no bundled model and no trial key inside the image, so tasks fail until you connect a provider. HarnessRouter itself is Apache-2.0. The agent CLIs are not covered by that licence, which is why they are fetched on first start rather than shipped in the image.

Self-host HarnessRouter with one docker run

docker pull harnessrouter/harnessrouter
docker run -d --name harnessrouter \
  -p 127.0.0.1:3000:3000 \
  -v harnessrouter:/data \
  harnessrouter/harnessrouter

Then watch the container come up. The first start is slow, and the logs tell you why.

docker logs -f harnessrouter

You will see lines like these while it works:

installing Claude Code (Anthropic's terms apply)…
installing Codex (Apache-2.0)…
installing Hermes (check its upstream license before use)…

Wait for ready on :3000. That install happens once per volume, so every later start takes a few seconds and prints no install lines at all.

Two facts follow from that download, and both matter on a VPS. First, first boot needs outbound network access. The image is not self-contained, so a box behind an egress filter, or one with no route out, hangs here and never prints ready on :3000. It fails at first start, not at docker pull, which is a confusing place to find out. Second, you are installing third-party software under third-party terms. Claude Code arrives under Anthropic's terms and Hermes under whatever its upstream says, so check both before you use this commercially.

-v harnessrouter:/data creates a named Docker volume. Everything durable lives in /data: the SQLite databases, the stored files, the secret store and the agent workspaces. Delete that volume and you have deleted the instance, including the provider keys and every transcript. Back it up with the container stopped, because copying a SQLite database while it is being written gives you a file that may not open. The same stop-then-copy discipline applies to every stateful container on the box, though the details vary by service, since PhotoPrism and Immich each need their own backup commands.

docker stop harnessrouter
docker run --rm -v harnessrouter:/data -v "$PWD":/backup alpine \
  tar czf /backup/harnessrouter-data.tgz -C / data
docker start harnessrouter

The compose variant, and the line you must change

The repository ships a compose file. It publishes "3000:3000", which means every interface on the host. Change that line before you bring it up on a public server.

services:
  harnessrouter:
    image: harnessrouter/harnessrouter:0.5.5
    ports:
      - "127.0.0.1:3000:3000"
    env_file:
      - .env
    volumes:
      - harnessrouter-data:/data
    restart: unless-stopped

volumes:
  harnessrouter-data:

Two things differ from upstream: the bind address, and a pinned version tag instead of latest. Pinning matters because sixteen version tags went out between 9 and 18 August 2026, and an agent runtime that changes under you is hard to debug. Then copy the environment file, lock its permissions, and start.

cp .env.example .env
chmod 600 .env
docker compose up -d
docker compose logs -f

.env holds your provider key in plain text, so mode 600 is the minimum. If the docker compose subcommand is unfamiliar, the Docker Compose command cheat sheet covers the daily verbs.

Why the port is published on 127.0.0.1 and not 0.0.0.0

-p 3000:3000 publishes the port on every interface the host has. -p 127.0.0.1:3000:3000 publishes it on loopback only, which means the only way in is from the VPS itself. The container always listens on 3000 inside, so the left-hand side is the part you change. Check what you got:

docker port harnessrouter
sudo ss -ltnp | grep 3000

ss printing 127.0.0.1:3000 is right. 0.0.0.0:3000 means the console is on the public internet. That is worse here than for most self-hosted apps, because the console creates harnesses, reads every transcript, runs agents, and gives those agents a shell and a real filesystem in their workspace. It also holds the provider key you connected. Anyone who reaches an unprotected console can read your work, run commands, and spend your key.

A host firewall does not save you from this. Docker publishes ports by writing its own rules into the kernel nat table, and those are evaluated before the chain ufw manages, so a published port stays reachable even when sudo ufw status lists it as denied. Test from another machine, not from the VPS, or you will test nothing. This is the same lesson as running dsh headless on port 3080: bind the service to loopback, then decide deliberately how you reach it.

Change the default login before anything else

Sign in at http://localhost:3000 with the username harnessrouter and the password harnessrouter. Those credentials are printed in the README because they are placeholders, not secrets, and the container warns you on every start until you change them:

using the DEFAULT password. Set HR_AUTH_PASSWORD, or change it from the profile page, before exposing this instance.

Change it from the Profile page, or set it at start time for a scripted deploy. HR_AUTH_USER and HR_AUTH_PASSWORD override the defaults.

docker run -d --name harnessrouter \
  -p 127.0.0.1:3000:3000 \
  -v harnessrouter:/data \
  -e HR_AUTH_USER='you' \
  -e HR_AUTH_PASSWORD='the-password-you-chose' \
  harnessrouter/harnessrouter

There is no reset email, because there is no account system and no mail server. If you lose the password, delete the auth file in the volume and restart, then sign in with the defaults again.

docker stop harnessrouter
docker run --rm -v harnessrouter:/data alpine rm -f /data/selfhost-auth.json
docker start harnessrouter

HR_AUTH_DISABLED=1 removes the sign-in gate completely. The README scopes it to "a box nobody else can reach". A VPS with a public IP address is not that box, so leave the gate on unless you are running this on a laptop.

Check your version, because the old ones have no gate

This is the part to take seriously. Versions 0.1.x and 0.2.0 shipped with no authentication gate at all: anyone who could reach port 3000 was already inside the console. 0.3.0 was the first release with a login. Those older tags are still published and still pullable, so an old pinned tag, or a compose file copied from a colleague, can put an ungated console on a public port today.

As of 19 August 2026 the newest published tag is 0.5.5, dated 18 August 2026, and latest points at it. Check what you have, then compare it against the tag list on Docker Hub:

docker image ls harnessrouter/harnessrouter

Anything below 0.3.0 should be replaced now, not scheduled. Anything at or above it still needs the password changed, because a default password and no password are the same thing to someone scanning port 3000. Do not treat the version numbers on this page as current. They were true on the date at the top, and this project ships fast.

Connect a provider

Nothing runs until a model provider is connected. Add one from the Integrations page in the console, or pass it to docker run in the environment. The value is JSON, so quote it in the shell:

-e HR_SECRET_GLOBAL_HARNESS_CONN_ANTHROPIC='{"name":"anthropic","provider":"anthropic","api_key":"sk-ant-…"}'

.env.example names one connection variable per provider family: HR_SECRET_GLOBAL_HARNESS_CONN_ANTHROPIC for the claude-code backend, HR_SECRET_GLOBAL_HARNESS_CONN_OPENAI for the codex backend, and HR_SECRET_GLOBAL_HARNESS_CONN_CUSTOM for any OpenAI-compatible endpoint, which is where an aggregator or your own inference server goes. The matching HR_SECRET_GLOBAL_HARNESS_POLICY_CLAUDE, HR_SECRET_GLOBAL_HARNESS_POLICY_CODEX and HR_SECRET_GLOBAL_HARNESS_POLICY_HERMES variables say which connection each backend uses by default. HR_SECRET_KEY is a separate thing, and it is only required when you connect a database to an agent.

HR_BACKENDS selects which backends load, as in HR_BACKENDS=claude,codex,hermes. One known issue is worth knowing before it bites you: any value that leaves out hermes makes the container exit immediately with status 1 and no error message. You see Exited (1) in docker ps -a a second after starting, and docker logs shows nothing useful. Keep hermes in the list until upstream fixes it. If Hermes is the only harness you want, running the Hermes agent on its own VPS is the smaller deployment.

Call the API without the console

The console is optional. The same API serves both, and it speaks a Responses-style contract. Sign in first to get a session cookie:

curl -c hr.cookies http://localhost:3000/api/selfhost/login \
  -H 'content-type: application/json' \
  -d '{"username":"harnessrouter","password":"your-password"}'

Then send a task, naming the harness in metadata.harness_id and a model your connected provider actually serves:

curl -s -b hr.cookies http://localhost:3000/api/harness/v1/responses \
  -H 'content-type: application/json' \
  -d '{"input":"Reply with exactly this and nothing else: it works.",
       "metadata":{"harness_id":"codex"},
       "model":"gpt-5.4-mini",
       "stream":false}'

A JSON object holding an output block and a token count means the harness ran. Changing harness_id from codex to claude sends the same request to a different harness, and that swap is the entire reason this software exists. The custom connection above is how you point a harness at an OpenAI-compatible endpoint you already host, in the way a self-hosted DeepSeek harness on a VPS is wired up.

Reach it from your laptop without publishing a port

Two ways, and neither of them is a raw port on 0.0.0.0.

An SSH tunnel is the cheapest, and it needs nothing installed on the server. It forwards a local port on your machine to loopback on the VPS.

ssh -N -L 3000:127.0.0.1:3000 you@your-vps

Leave that running and open http://localhost:3000 in your browser. If SSH prints bind: Address already in use, something on your laptop already holds port 3000, so pick another local port with -L 3100:127.0.0.1:3000 and browse to port 3100.

A terminating reverse proxy is the answer when other people need access. The proxy holds the TLS (transport layer security) certificate and forwards to loopback. The README gives a Caddy config:

console.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:3000 {
        flush_interval -1      # agent turns stream for minutes; never buffer them
    }
}

flush_interval -1 is the line people miss. Agent turns stream tokens for minutes, and a proxy that buffers the response holds those tokens until the turn ends, so the console looks frozen and then prints everything at once. The equivalent in Nginx is proxy_buffering off; inside the location block. Whichever you pick, keep the DNS name pointed at the proxy and the container on loopback. Comparing Nginx, Caddy and Traefik as a reverse proxy covers which one fits your box.

Run it as its own user, not as root

The Docker daemon runs as root, and membership of the docker group is equivalent to root, because a member can start a container that mounts the host filesystem. So "add the team to the docker group" hands out root on the box that holds your provider key.

The simple version: create a service account that owns the compose file and .env, and keep those files out of any shared home directory.

sudo adduser --disabled-password --gecos "" harness
sudo install -d -o harness -g harness -m 750 /srv/harnessrouter

The stronger version is rootless Docker, where the daemon itself runs as that unprivileged user. It needs the uidmap package for newuidmap and newgidmap, and at least 65536 subordinate UIDs in /etc/subuid and /etc/subgid for the user.

sudo apt install -y uidmap docker-ce-rootless-extras
sudo loginctl enable-linger harness
sudo -iu harness
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
systemctl --user enable --now docker

loginctl enable-linger is not optional here. Without it the user's systemd instance stops when the last session closes, so the container dies when you log out. Confirm the result with docker info, which lists rootless under Security Options. Rootless mode cannot bind ports below 1024 without extra configuration, which does not matter here because port 3000 is above that line. Setting up the account itself is covered in creating least-privilege users on a VPS.

What breaks, and what you will see

The container exits a second after starting and the logs are empty. docker ps -a shows Exited (1). That is the HR_BACKENDS issue above: your value left out hermes. Put it back.

First start never finishes. The log stops after an installing line and ready on :3000 never appears. The box cannot reach the network to fetch the agent CLIs, because they are not in the image. Fix the outbound route or the proxy settings, then restart.

The console loads and every task fails. No provider is connected. There is no bundled model and no free tier inside the image, so a fresh instance can sign you in and still run nothing.

The console freezes mid-answer behind a proxy. The output appears in one block when the turn ends. That is response buffering. Set flush_interval -1 in Caddy, or proxy_buffering off; in Nginx.

You cannot reach it from your laptop and the tunnel is up. Run docker port harnessrouter on the server. If it prints nothing, the container publishes nothing, so it was started without -p.

Is this worth running?

It is worth running if you genuinely use more than one harness and you want one endpoint and one credential store instead of three of each. It is also worth running if you are building a product on top and want the harness to be a configuration value rather than a rewrite. That is what UHP buys you, with the caveat above about how young the protocol is.

It is not worth running if you use one harness. Installing that CLI on the server is fewer moving parts, and no login sits between you and it. It is also the wrong shape if what you want is several agents cooperating on one task rather than one API in front of several harnesses, which is a different tool: see a multi-agent harness such as Omnigent for that pattern. Either way the deployment rules do not change. Loopback bind, changed password, a pinned tag at 0.3.0 or above, and its own user.

FAQ

Is it safe to publish HarnessRouter on port 3000?

No. The console creates harnesses, reads every transcript, runs agents with shell and filesystem access, and holds the provider key you connected, so an open port exposes all of it. Publish on loopback with -p 127.0.0.1:3000:3000 and reach it through an SSH tunnel or a TLS-terminating reverse proxy. A host firewall is not enough on its own: Docker writes its own rules into the kernel nat table, so a published port answers from the internet even when ufw shows it as denied. Verify with sudo ss -ltnp | grep 3000, which should print 127.0.0.1:3000.

Which HarnessRouter version added the login gate?

0.3.0. Versions 0.1.x and 0.2.0 shipped with no authentication at all, and both tags are still published and still pullable, so anyone running them is relying on nobody finding the port. As of 19 August 2026 the newest tag is 0.5.5, dated 18 August 2026. Run docker image ls harnessrouter/harnessrouter to see what you have, compare that against the tag list on Docker Hub rather than against this page, and change the default password even on a current version.

Why does the container exit straight after I set HR_BACKENDS?

Any HR_BACKENDS value that leaves out hermes makes the container exit immediately with status 1 and no error message, which is a known issue in the project README. The symptom is Exited (1) in docker ps -a within a second or two, and nothing useful in docker logs. Keep hermes in the list, as in HR_BACKENDS=claude,codex,hermes, until upstream fixes it.

Does HarnessRouter need internet access on first start?

Yes. The agent CLIs are fetched on first start instead of being shipped in the image, because each one carries its own licence. A box with no outbound route prints the installing lines and then never reaches ready on :3000. The download happens once per volume, so later starts take a few seconds and need no network beyond the model provider you connected.

I lost the console password. How do I get back in?

There is no reset email, because there is no account system and no mail server. Stop the container, delete /data/selfhost-auth.json from the volume, start it again, then sign in with the default credentials and set a new password from the Profile page. With the container and volume both named harnessrouter, that is docker stop harnessrouter, then docker run --rm -v harnessrouter:/data alpine rm -f /data/selfhost-auth.json, then docker start harnessrouter.