Skip to main content
luke@terminal:/$ ls
luke@terminal:/blog$ cat hermes-profiles-to-docker.md

Why I Ditched Hermes Profiles for Docker Containers

2026-07-19 2026-08-07 3 min read

I ran Hermes (the self-hosted agent I use for reading and research and a few other jobs) with three profiles: default, sage, and rex. Each was a separate persona with its own skills and its own Telegram bot.

They were gateway profiles. In Hermes, the gateway is the long-running process that polls Telegram, runs scheduled jobs, and executes skills. "Profiles" means one gateway binary, one process manager, just different configs passed in via --profile.

One thing worth saying up front. This post is the "why I tried containers" half of the story. When I went to verify the setup, it wasn't actually working yet, and that's the next post. I'm keeping this one focused on the reasoning and the container plumbing, because that part still stands even though my diagnosis turned out to be off.

The Problem I Didn't Know I Had

The symptom was cron replies coming from the wrong profile, and it was inconsistent, which is what made it hard to pin down. Sometimes a scheduled job would fire and default and sage would both reply. Sometimes only rex would send the update, when it wasn't his job to. It never happened with messages I sent directly — only cron.

My theory at the time was that the profiles shared too much. One scheduler, one process tree, no hard boundary between them, so jobs bled across. Three profiles all live in the same process, so when a cron job fired, I figured whichever gateway instance was free picked it up.

That theory turned out to be mostly wrong, which is a whole separate story. I'm laying it out anyway, because it's what drove me to containers, and the architecture reasoning is sound even if the diagnosis wasn't.

What I didn't realise at the time: gateway profiles aren't separate services. They're separate config directories, separate skill directories, separate .env files, and a --profile flag handed to the same gateway binary. Same process tree. Same supervisor. No hard boundary when one of them misbehaves.

I'd been treating them like independent services. They're not.

What the Docs Recommended (and Why I Went Further)

The Hermes docs recommend one container hosting all profiles, with s6-overlay (a process supervisor designed for containers) managing each profile as a first-class service. Mount the whole ~/.hermes directory to /opt/data, create profiles with hermes profile create, let s6 start and stop them.

I didn't think that would solve what I was seeing. With one container and s6 running every profile, sage and rex are still co-located processes sharing a network namespace and a data directory. If the problem was jobs bleeding across profiles, bundling them into one container wouldn't stop any of it. They'd just be supervised versions of the same shared setup.

So I ran separate containers for sage and rex, each mounting only its own profile directory. Real process isolation: separate network namespaces, separate PID 1, separate Telegram polling loops. At the process level they genuinely cannot interfere with each other.

The architecture was right, it just wasn't the cause of what I was seeing. That part's in the next post.

The Mount Path Mistake

The mount paths. I spent far too long on this.

The profile needs to live at /opt/data/profiles/<name> inside the container, not at /opt/data. I kept mounting the rex profile to /opt/data, and Hermes would log Error: Profile 'rex' does not exist. Create it with: hermes profile create rex. Same files on the host, same mount command, but Hermes couldn't find the profile because it was looking in the wrong place.

The fix was obvious once I saw it:

# Wrong — mounts to /opt/data, Hermes expects /opt/data/profiles/rex
- /home/luke/.hermes/profiles/rex:/opt/data

# Right
- /home/luke/.hermes/profiles/rex:/opt/data/profiles/rex

The containers weren't the wrong architecture. The paths were wrong. A simple mistake that cost more time than it should have.

The Boot Problem

Containers don't start themselves. Bare-metal services auto-start through systemd. I needed the Docker containers to come up on boot too.

docker compose has no native systemd integration. The cleanest approach was a systemd user service that runs docker compose up -d for each profile's compose file. It feels slightly off, systemd managing containers instead of services, but it's straightforward and it works.

What I'd Tell Myself

When I was running three gateway profiles on bare metal, I was already past what profiles are designed for. They're great for development. Spinning up a new persona with different skills takes seconds. For anything that needs to actually stay separated, the isolation story falls apart.

Containers were the right architecture for that. The operational complexity is real (two compose files, a custom systemd service, separate log streams), but it's the right kind of complexity. Explicit, manageable, debuggable. When rex goes wrong, I look at the rex container, not a shared process tree.

I still run default bare-metal. Default is the daily driver; it has no scheduled cron that fires critical replies. Sage and rex are the workers. They get containers.

What I got wrong was assuming that building the right architecture meant the problem was solved. It didn't. When I went to verify the containers were actually doing their job, nothing worked. Sage was running two Hermes instances, cron jobs were vanishing into the wrong database, and the symptom that started all of this was still happening.

That's the next post.