Running an agent in a container is not the same problem as running a web app in a container. A web app is stateless and reads config. An agent writes state constantly — session history, memory, skills, cron definitions, downloaded scripts — and expects a shell underneath it.
The install itself is short. What follows is the list of things that fail in ways which do not obviously point at the cause.
What is being installed
A single container running the agent gateway, with:
- a named volume for all persistent state (
/data) - configuration mounted from
config.yaml - the dashboard exposed on a local port
- credentials injected at runtime, never baked into the image
The image ships with a full skills tree baked in. That detail causes trap #2.
Preflight
docker --version
docker compose version # or plain docker run
# GHCR auth — the image is private
docker login ghcr.io -u <user>
The token needs read:packages. A token without it authenticates successfully and then fails to pull, which reads like a network problem.
Seed the data volume from the image
This is the step that separates a working container from one that starts with an empty skills directory.
The image bakes a populated tree at /data, but a fresh named volume mounts over it — so the volume starts empty and the baked content is hidden. Seed it once:
docker create --name hermes-seed <image>
docker cp hermes-seed:/data/. ./seed/
docker rm hermes-seed
docker volume create hermes-data
docker run --rm -v hermes-data:/data -v "$PWD/seed:/seed:ro" \
busybox sh -c 'cp -a /seed/. /data/ && chown -R 10000:10000 /data'
Skipping this produces a container that runs perfectly and answers “I have no skills available” — a symptom that looks like a broken image, not a missing seed.
Credentials
Never bake keys into the image or the compose file. Two acceptable patterns:
# env file, chmod 600, outside the build context
docker run --env-file ~/.hermes/.env ...
# or a secrets mount, read-only
docker run -v ~/.hermes/.env:/run/secrets/hermes.env:ro ...
There is exactly one config value that must change from its default for a containerised run: any path pointing at the host home directory. Inside the container, everything lives under /data. A config that still says /home/user/... will start fine and write into an ephemeral layer that vanishes on recreate.
Start the container
services:
hermes:
image: ghcr.io/<org>/hermes:<tag>
container_name: hermes
command: gateway # NOT optional — see trap #3
restart: unless-stopped
env_file: /home/faiez/.hermes/.env
environment:
HERMES_UID: "1000"
HERMES_GID: "1000"
HERMES_DASHBOARD: "1"
ports:
- "127.0.0.1:9119:9119"
volumes:
- hermes-data:/data
- /home/faiez/.hermes/config.yaml:/data/config.yaml:ro
Verify
docker ps # Up, not Restarting
docker logs hermes --tail 50 # gateway started, no credential errors
curl -s http://127.0.0.1:9119/healthz
docker exec hermes sh -c 'ls /data | head' # skills, memory, sessions present
The docker exec line is the one that matters. A gateway that answers health checks while /data is empty will fail the first time it tries to load a skill — at which point the symptom is “the agent is broken”, hours later.
The seven traps
| Symptom | Cause | Fix |
|---|---|---|
denied / unauthorized on pull | token lacks read:packages | re-login, confirm with docker manifest inspect |
| 58 skills, none of mine | volume mounted empty, baked tree hidden | seed from the image, then recreate |
Container exits immediately: Input is not a terminal | no command: given | add gateway |
ERROR ... started with --user | user: in compose, or --user on docker | drop it, use HERMES_UID / HERMES_GID |
Gateway DEGRADED, exit 2, restart loop | a platform enabled without credentials | disable that platform or supply pairing |
| Dashboard unreachable, app healthy | HERMES_DASHBOARD unset | set to 1 |
Files on the volume owned by 10000 | seeded but never booted, or HERMES_UID unset | set the UID, recreate |
Trap #3 deserves emphasis. Without command: gateway, the container’s default entrypoint starts an interactive CLI, finds no TTY, and exits. docker ps shows the container gone within a second, and --restart unless-stopped turns that into a restart loop that can saturate the daemon’s logs. The fix is one line and the error message (Input is not a terminal) is at least honest about it.
Trap #4 is counterintuitive if you come from a web-app background: setting user: in compose — normally good practice — makes this image refuse to start. It manages its own UID mapping; running it under a forced user breaks that assumption.
Trap #5 is a design decision, not a bug. If a platform adapter is enabled but has no credentials, the gateway reports DEGRADED and exits 2 rather than running half-configured. Read the log line naming the platform, then disable it explicitly.
Update and rollback
docker compose pull
docker compose up -d
docker compose logs -f --tail 100
Keep the previous image tag pinned in compose so rollback is a one-line revert, and snapshot the volume before an upgrade:
docker run --rm -v hermes-data:/data -v "$PWD:/backup" busybox \
tar czf /backup/hermes-data-$(date +%F).tar.gz -C /data .
The volume is the only stateful thing here. Treat it like a database: back it up before every version change, and never docker compose down -v casually — that flag deletes it.
What I would do differently
Run the gateway container as the only writer to /data and mount everything else read-only. Mixing a read-write config mount with a read-write volume means two sources of truth for the same file, and the container will silently prefer whichever it opened last.
And pin the tag. latest on an agent image means a container that behaves differently after a restart, with no change on your side and no rollback target.
Leave a Comment / Ask a Question
Have thoughts or questions on this article? Drop a message below and Faiez will receive an instant notification.