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

SymptomCauseFix
denied / unauthorized on pulltoken lacks read:packagesre-login, confirm with docker manifest inspect
58 skills, none of minevolume mounted empty, baked tree hiddenseed from the image, then recreate
Container exits immediately: Input is not a terminalno command: givenadd gateway
ERROR ... started with --useruser: in compose, or --user on dockerdrop it, use HERMES_UID / HERMES_GID
Gateway DEGRADED, exit 2, restart loopa platform enabled without credentialsdisable that platform or supply pairing
Dashboard unreachable, app healthyHERMES_DASHBOARD unsetset to 1
Files on the volume owned by 10000seeded but never booted, or HERMES_UID unsetset 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.