Hermes Agent wants to live somewhere. It's a persistent agent, so the whole point is that it keeps running: the gateway answers messages, cron jobs fire on schedule, and memory piles up on disk. My laptop isn't that place. For a while a Coolify box was. This week I moved my Hermes bots to Fly.io, each one on a single Machine with the Hermes WebUI running next to it.
Here's the setup, file by file. It's two config files and four commands, and most of the interesting decisions are in the first file.
The shape of it
One Fly Machine, three containers, one volume:
hermes runs gateway run, the long-lived Hermes process that talks to your messaging platforms and runs scheduled jobs.webui runs the browser UI. This is the only thing exposed to the internet.src-sync runs once at boot, copies the agent's source onto the volume, and exits.
All three mount the same volume at /opt/data. That's the trick that makes the whole thing work. The agent image already uses /opt/data as its HERMES_HOME, so if the WebUI reads the same directory, both of them see the same config, memory, skills and sessions. You chat in the browser and the gateway knows about it. You message the bot from your phone and the session shows up in the WebUI sidebar.
Fly calls this a multi-container Machine. You describe the containers in a JSON file and point fly.toml at it.
fly.toml
app = "my-hermes"
primary_region = "yyz"
machine_config = "machine_config.json"
[build]
image = "nousresearch/hermes-agent:latest"
[[mounts]]
source = "data"
destination = "/opt/data"
[http_service]
internal_port = 8787
force_https = true
auto_stop_machines = "off"
auto_start_machines = true
min_machines_running = 1
[[vm]]
memory = "4gb"
cpus = 2
cpu_kind = "shared"
Two settings matter more than they look.
auto_stop_machines = "off" is the one people get wrong. Fly's default is to stop idle Machines and wake them on the next HTTP request. That's great for a web app and terrible for an agent. A message to your Telegram bot never hits the Machine as a web request, and a cron job can't fire on a stopped Machine. Keep it on.
internal_port = 8787 is the WebUI's port. The gateway doesn't listen on anything public, so the only door into this Machine is the WebUI, and that door has a password (more on that below).
I give it 4 GB. The WebUI runs its own copy of the agent in-process, so you're paying for two Python agents on one box, plus whatever the browser tools pull in.
machine_config.json
This is where the real work happens. Here it is in full, then the three containers one at a time.
{
"containers": [
{
"name": "src-sync",
"image": "nousresearch/hermes-agent:latest",
"entrypoint": ["/bin/sh", "-c"],
"cmd": [
"set -e; rm -rf /opt/data/hermes-agent.new; mkdir -p /opt/data/hermes-agent.new; tar -C /opt/hermes --exclude=./.venv --exclude=./.playwright --exclude=./node_modules --exclude='./tools/chromium-*' --exclude=./.git -cf - . | tar -C /opt/data/hermes-agent.new -xf -; rm -rf /opt/data/hermes-agent; mv /opt/data/hermes-agent.new /opt/data/hermes-agent; echo src-sync done"
],
"restart": { "policy": "no" }
},
{
"name": "hermes",
"image": "nousresearch/hermes-agent:latest",
"cmd": ["gateway", "run"],
"secrets": [
{ "env_var": "OPENAI_API_KEY" },
{ "env_var": "BROWSER_USE_API_KEY" }
]
},
{
"name": "webui",
"image": "ghcr.io/nesquena/hermes-webui:latest",
"entrypoint": ["/bin/sh", "-c"],
"cmd": [
"rm -rf /home/hermeswebui/.hermes && ln -s /opt/data /home/hermeswebui/.hermes && mkdir -p /opt/data/workspace && rm -rf /workspace && ln -s /opt/data/workspace /workspace && exec /hermeswebui_init.bash"
],
"env": {
"HERMES_HOME": "/opt/data",
"HERMES_WEBUI_HOST": "0.0.0.0",
"HERMES_WEBUI_PORT": "8787",
"HERMES_WEBUI_STATE_DIR": "/opt/data/webui",
"HERMES_WEBUI_DEFAULT_WORKSPACE": "/workspace"
},
"secrets": [{ "env_var": "HERMES_WEBUI_PASSWORD" }],
"depends_on": [
{ "name": "src-sync", "condition": "exited_successfully" }
],
"healthchecks": [
{
"name": "webui",
"http": { "port": 8787, "method": "GET", "path": "/health", "scheme": "http" },
"grace_period": 120,
"interval": 30,
"timeout": 5,
"failure_threshold": 5
}
]
}
]
}
src-sync: why the WebUI needs a copy of the agent
The WebUI runs the agent in-process, but its image doesn't include the agent's code. At startup it goes looking for the source, checking $HERMES_HOME/hermes-agent first. On a laptop that directory exists because the installer put it there. In a container it doesn't, because the agent image keeps its code at /opt/hermes, which is a different container's filesystem.
So src-sync boots from the agent image, tars up /opt/hermes, and unpacks it onto the shared volume at /opt/data/hermes-agent. It skips the bulky stuff the WebUI doesn't need: the virtualenv, Playwright, node_modules, the bundled Chromium, .git.
Two details are deliberate. It extracts into hermes-agent.new and then swaps it in with mv, so the WebUI never sees a half-written tree. And it runs on every boot, which means that when you redeploy with a newer agent image, the WebUI's in-process agent gets upgraded too. Without that, the gateway and the WebUI drift onto different Hermes versions writing to the same files. That's how I'd expect sessions to get weird.
"restart": { "policy": "no" } tells Fly it's a one-shot job. The WebUI's depends_on with exited_successfully makes it wait until the copy is done.
hermes: the gateway
This one's short. Same agent image, gateway run as the command, and the API keys it needs. Its HERMES_HOME is already /opt/data from the image, so it needs no path setup.
webui: symlink the home directory
You'd think setting HERMES_HOME=/opt/data on the WebUI container would be enough. I set it, and I also symlink ~/.hermes to /opt/data before starting the WebUI's own init script. Belt and braces, and there's a reason for the paranoia.
While moving other apps over I hit a nasty one: on multi-container Machines, an image's own ENV values won over the env block in machine_config.json for any key the image already defined. My override was there in the config and silently ignored. The symlink doesn't care whose environment wins. Whatever the WebUI decides its home is, it lands on the volume.
Same idea for /workspace. It points at /opt/data/workspace, so files the agent writes from the WebUI survive a restart instead of vanishing with the container.
The health check hits /health with a 120-second grace period. First boot is slow while src-sync copies the source, and you don't want Fly killing the Machine for being late.
Keeping secrets out of everything
Nothing secret lives in either file. Both are safe to commit.
API keys go in as Fly secrets, and each container lists exactly the ones it gets. The gateway gets the model and browser keys. The WebUI gets one secret: its password. If the public-facing container ever gets popped, it isn't holding your OpenAI key in its environment.
Set HERMES_WEBUI_PASSWORD. The WebUI's own docs say password protection is required once you bind past 127.0.0.1, and this one is on the open internet with a shell-capable agent behind it. Use a long random password.
Set secrets from a file rather than on the command line, so they stay out of your shell history:
OPENAI_API_KEY=sk-...
BROWSER_USE_API_KEY=...
HERMES_WEBUI_PASSWORD=...
fly secrets import -a my-hermes --stage < secrets.env
--stage holds them until the next deploy instead of restarting a Machine that doesn't exist yet.
One more place keys hide: the volume. Hermes keeps its config and .env under HERMES_HOME, so anything you configure through hermes setup lands on /opt/data. Treat volume snapshots like you'd treat the secrets file.
Deploying it
fly apps create my-hermes
fly volumes create data -a my-hermes --region yyz --size 5
fly secrets import -a my-hermes --stage < secrets.env
fly deploy -a my-hermes --ha=false
--ha=false matters. By default Fly creates two Machines for redundancy, but a volume belongs to exactly one Machine, and two agents with two separate memories is the opposite of what you want. Stick to one Machine.
Size the volume for growth. The synced source is small, but sessions, memory and the workspace grow for as long as the agent runs. 5 GB is a comfortable start, and Fly volumes can be extended later.
Once it's up, configure the agent from inside the gateway container:
fly ssh console -a my-hermes --container hermes
hermes setup
--container is how you pick which of the three you land in. Then open https://my-hermes.fly.dev, log in with the password, and you're talking to the same agent the gateway runs.
What I'd tell you before you copy this
Once the first bot worked, the rest were mostly copy and paste. I wrote a small script that generates both files from a bot name, a list of secret names and some env vars, and now each new bot is one command plus a deploy.
The tradeoff is that every bot gets its own 4 GB Machine, and that adds up faster than one fat VPS running everything. What I get for it is isolation. One bot's runaway browser session can't starve another, a bad deploy only takes down one bot, and each one has its own volume I can snapshot or throw away.
If you're new to Hermes itself, the full manual, install through day-two operations, is Mastering Hermes. And since this setup hands a shell-capable agent to the internet behind one password, Agent Guardrails is worth an hour before you deploy.