ghostship-hermes builds and publishes ghcr.io/caelx/ghostship-hermes, an Ubuntu 24.04 Hermes workstation image with:
- upstream Hermes
0.9dashboard - upstream Hermes gateway runtime
- repo-owned Discord forced-channel routing patch
- repo-owned dashboard
Terminalentry backed by same-originttyd - persisted
/home/hermes,/workspace, and/nix
The image is intentionally not NixOS. Docker owns container lifecycle. s6-overlay owns in-container supervision. Hermes owns ~/.hermes.
- Base image:
ubuntu:24.04 - PID 1:
s6-overlay - Runtime user:
hermes(3000:3000) - Hermes core:
/opt/hermes - Canonical persisted home:
/home/hermes - Canonical Hermes home:
/home/hermes/.hermes - Canonical workspace:
/workspace - Canonical userland Nix store:
/nix - Public web surface:
0.0.0.0:7681 - Internal services:
- dashboard:
127.0.0.1:9119 - ttyd: unix socket at
/run/user/3000/ttyd.sock
- dashboard:
This is a workstation container. Use terminal.backend: local. Do not use nested Docker terminal sandboxes for the normal path.
Immutable image-owned layer:
- Ubuntu base OS
- Hermes core in
/opt/hermes s6,nginx,ttyd- repo-owned Hermes patches:
- Discord Codex-pinned channel
- dashboard
Terminalentry
- baked fixed environment defaults
Persistent downstream-owned layer:
/home/hermes~/.hermes~/.config~/.local~/.npm~/.cargo~/.rustup~/.codex~/.opencode~/.ssh- shell history and other userland state
/workspace/nix
Package ownership split:
- image: Hermes core, dashboard/runtime services, and the small operator utility bundle for the workstation contract
- native npm seed in persisted home:
codex,gemini-cli,agent-browser,opencode - image-managed Nix defaults:
bw,gh,gcloud,gws,blogwatcher-cli - image-managed local browser tooling: native CloakBrowser under
/opt/ghostshiplaunched throughagent-browser, with the persistent Chrome profile rooted at/home/hermes/.local/state/cloakbrowser - image-managed reference docs:
/home/hermes/ghostship-wiki, seeded fromdocs/ghostship-wikiplus the restoreddocs/apireference set - persisted Nix user profile: extra downstream or Hermes-installed packages on top of the image defaults
The Dockerfile is intentionally split into two stages:
base: Ubuntu + Hermes core + system/runtime dependencies only, with no Ghostship-specific overlay contentfinal: dashboard patch, runtime rootfs, seeded userland defaults, exported managed Nix default-tool closure, and other repo-owned overlay content
Local image build:
docker build \
--target final \
--build-arg HERMES_REF=(string trim < packages/hermes-image/hermes-release.txt) \
--tag ghostship-hermes:dev \
--file packages/hermes-image/Dockerfile \
.Or use the helper:
scripts/export_publishable_image.sh ghostship-hermes:devThe container is intentionally not a single-process CMD wrapper. It is a workstation-style container with Docker/Podman owning the outer lifecycle and s6 owning the in-container long-running services.
Service topology:
nginxbinds0.0.0.0:7681- upstream Hermes dashboard listens on
127.0.0.1:9119 ttydlistens on unix socket/run/user/3000/ttyd.socknginxproxies:/-> upstream Hermes dashboard/terminal/-> same-originttyd
- Hermes gateway runs in-container and is supervised by
s6
Operational consequences:
- do not run
hermes gateway installin the container - do not install systemd units in the container
- use
terminal.backend: local - protect
:7681with Cloudflare Access or equivalent upstream auth; the image does not add its own auth layer - downstream Hermes/plugin env such as
FIRECRAWL_API_KEYis projected into the Hermes runtime by default; image-owned and other service-only env stays excluded from the Hermes service
Minimal docker run:
docker run -d \
--name ghostship-hermes \
--restart unless-stopped \
--publish 7681:7681 \
--env-file ./.env \
--volume ghostship-hermes-home:/home/hermes \
--volume ghostship-hermes-workspace:/workspace \
--volume ghostship-hermes-nix:/nix \
ghcr.io/caelx/ghostship-hermes:latestPodman works too:
podman run -d \
--name ghostship-hermes \
--restart unless-stopped \
--publish 7681:7681 \
--env-file ./.env \
--volume ghostship-hermes-home:/home/hermes \
--volume ghostship-hermes-workspace:/workspace \
--volume ghostship-hermes-nix:/nix \
ghcr.io/caelx/ghostship-hermes:latestExample docker compose service:
services:
hermes:
image: ghcr.io/caelx/ghostship-hermes:latest
container_name: ghostship-hermes
restart: unless-stopped
ports:
- "7681:7681"
env_file:
- .env
volumes:
- ghostship-hermes-home:/home/hermes
- ghostship-hermes-workspace:/workspace
- ghostship-hermes-nix:/nix
volumes:
ghostship-hermes-home:
ghostship-hermes-workspace:
ghostship-hermes-nix:Downstream must persist all three of these together:
/home/hermes/workspace/nix
What each mount owns:
/home/hermes- Hermes config, sessions, memories, skills, logs
/home/hermes/.hermes/auth.json- npm-installed CLIs and user config under
.local,.config,.npm,.codex,.opencode,.ssh, and similar
/workspace- project checkouts and work products
/nix- image-managed Nix default-tool profile payload
- operator-installed or Hermes-installed Nix packages and build outputs
Rules for coherent persistence:
- persist the whole
/home/hermestree, not selected dot-directories - reuse the same
/home/hermes,/workspace, and/nixmounts together when you recreate the container - keep the runtime user ownership coherent; bind mounts should be writable by UID/GID
3000:3000 - do not delete or replace
/nixif you expectnix profile addinstalls to survive container replacement - do not point multiple unrelated Hermes deployments at the same
/home/hermes - do not move Hermes core into
/home/hermes;/opt/hermesstays image-owned so image replacement cleanly updates Hermes itself
First boot behavior:
- the image creates the home/runtime directories it needs under
/home/hermes - the image seeds the home defaults and npm CLIs into the persisted home if they are missing
- the image auto-seeds an empty persisted
/nixfrom the image on first boot - the image reconciles the current image-managed Nix default profile into reused non-empty
/nixmounts on every boot without deleting user-managed Nix content
Detailed downstream persistence guidance still lives in docs/workstation-image.md.
Two env layers exist:
- Fixed image defaults baked into the image
- Downstream operator env supplied at runtime
These are internal image-owned variables. Downstream must not set or override them through --env, --env-file, Compose environment:, or a persisted .env.
HOME=/home/hermesHERMES_HOME=/home/hermes/.hermesXDG_CONFIG_HOME=/home/hermes/.configXDG_CACHE_HOME=/home/hermes/.cacheXDG_DATA_HOME=/home/hermes/.local/shareNPM_CONFIG_PREFIX=/home/hermes/.localCARGO_HOME=/home/hermes/.cargoRUSTUP_HOME=/home/hermes/.rustupNIXPKGS_ALLOW_UNFREE=1NIX_CONFIG=experimental-features = nix-command flakesGHOSTSHIP_WORKSPACE_ROOT=/workspaceGHOSTSHIP_WEB_PORT=7681GHOSTSHIP_DASHBOARD_HOST=127.0.0.1GHOSTSHIP_DASHBOARD_PORT=9119GHOSTSHIP_NIX_DEFAULT_PROFILE=/nix/var/nix/profiles/per-user/hermes/ghostship-defaultsDISCORD_REACTIONS=falseDISCORD_REQUIRE_MENTION=falseDISCORD_AUTO_THREAD=trueGHOSTSHIP_TTYD_SOCKET=/run/user/3000/ttyd.sockGHOSTSHIP_TTYD_BASE_PATH=/terminalGHOSTSHIP_TERMINAL_CWD=/workspace
These variables are internal because they define the persisted home layout, XDG layout, native tool install roots, and internal service topology for the workstation container. Overriding them makes the persistence contract incoherent and is unsupported.
The image PATH prefers:
/home/hermes/.local/bin/home/hermes/.cargo/bin/home/hermes/.nix-profile/bin/nix/var/nix/profiles/per-user/hermes/ghostship-defaults/bin/opt/ghostship/bin/opt/hermes/venv/bin
Downstream-owned env vars should go in exactly one of these places:
- preferred: the container runtime env, via
--env-file ./.env, Composeenv_file:, or Composeenvironment: - optional:
/home/hermes/.hermes/.envif you want the same supported Hermes env persisted into home state
Important rule:
- the image projects supported Hermes env into both
/run/ghostship/hermes.envand/home/hermes/.hermes/.envon boot /run/ghostship/hermes.envis the live service-facing file for the managed gateway and dashboard/home/hermes/.hermes/.envis the persisted home-state copy of that same managed env inventory- non-managed keys already present in
/home/hermes/.hermes/.envare preserved - managed keys in
/home/hermes/.hermes/.envare image-owned and may be refreshed or removed when runtime env changes
Required for the default Ollama Pro primary lane and OpenCode Go fallback:
OLLAMA_API_KEYOPENCODE_GO_API_KEYGOOGLE_AI_STUDIO_API_KEY
Required when Discord gateway is enabled:
DISCORD_BOT_TOKENDISCORD_ALLOWED_USERSDISCORD_HOME_CHANNELDISCORD_FREE_RESPONSE_CHANNELSGHOSTSHIP_CODEX_CHANNELDISCORD_WEBHOOK_CHANNEL
Recommended optional operator env:
WEBHOOK_SECRETBW_CLIENTID,BW_CLIENTSECRET, andBW_PASSWORDfor model-authored Bitwarden workflowsBITWARDENCLI_APPDATA_DIR=/home/hermes/.local/state/bitwarden-cliGITHUB_TOKEN
Supported but not recommended for downstream:
BROWSERBASE_API_KEYBROWSERBASE_PROJECT_IDBROWSER_USE_API_KEY
Important behavior:
DISCORD_HOME_CHANNELis the downstream-owned Discord home channel id; set it to#assistant.DISCORD_REACTIONSandDISCORD_REQUIRE_MENTIONdefault tofalse;DISCORD_AUTO_THREADdefaults totrueso Discord sessions run in threads by default.DISCORD_FREE_RESPONSE_CHANNELSis the upstream Hermes comma-separated free-response channel list.GHOSTSHIP_CODEX_CHANNELpins replies to Codex channelopenai-codex/gpt-5.5; set it to#foodstamps.DISCORD_FREE_RESPONSE_CHANNELSmust include the#foodstampschannel id.DISCORD_WEBHOOK_CHANNELis the default Discord destination forhermes webhook subscribe --deliver discordwhen--deliver-chat-idis omitted; set it to#webhooks./modelcannot override the Codex-pinned#foodstampssessions, including sessions inside Discord threads.- Closed, archived, locked, deleted, or inaccessible Discord thread sessions are retired by the managed gateway after 05:00 local Hermes time; historical SQLite transcripts are preserved.
Codex OAuth is not an env var. Run
hermes authorhermes modelin the container. Hermes stores Codex auth in/home/hermes/.hermes/auth.json, so it persists with the home volume and backs the forced#foodstampsCodex lane.
The full fixed env contract is also documented in docs/runtime-env.md.
Dashboard:
- upstream Hermes dashboard is the primary UI
- repo patch adds one
Terminalentry only Terminalrenders an embedded iframe for/terminal/, which is served byttyd
Models:
- Hermes default config calls Ollama Pro directly through the
ollama-procustom provider - primary model is
custom:ollama-pro/deepseek-v4-pro:cloud - fallback model is
opencode-go/deepseek-v4-pro - Hermes default config sets
web.backend: firecrawl - reused persisted homes are converged away from older managed model lanes and the old local router provider/URL fields
Forced Discord channels:
GHOSTSHIP_CODEX_CHANNELpins#foodstampsreplies, including thread replies, toopenai-codex/gpt-5.5./modeldoes not override that forced channel
Inside the container, manage Hermes like a normal host install:
hermes setuphermes modelhermes auth- edit
/home/hermes/.hermes/config.yaml - edit
/home/hermes/.hermes/.env
Do not use hermes gateway install inside the container. s6 already supervises hermes gateway run, hermes dashboard, ttyd, and nginx.
After the first successful container boot:
- authenticate Codex if you use the forced
#foodstampsCodex channel - verify provider and gateway env are present in both
/run/ghostship/hermes.envand/home/hermes/.hermes/.env - inspect
config.yamlonce and confirm the expected Ollama Pro and OpenCode Go defaults - run
hermes doctor - open the dashboard and confirm
/terminal/works through the same origin
Recommended post-setup flow:
docker exec --user 3000:3000 --env HOME=/home/hermes --env HERMES_HOME=/home/hermes/.hermes --env PATH=/opt/ghostship/bin:/opt/hermes/venv/bin:/home/hermes/.local/bin:/home/hermes/.nix-profile/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ghostship-hermes /bin/sh -lc '/opt/hermes/venv/bin/hermes auth'
docker exec --user 3000:3000 --env HOME=/home/hermes --env HERMES_HOME=/home/hermes/.hermes --env PATH=/opt/ghostship/bin:/opt/hermes/venv/bin:/home/hermes/.local/bin:/home/hermes/.nix-profile/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ghostship-hermes /bin/sh -lc '/opt/hermes/venv/bin/hermes doctor'
docker exec --user 3000:3000 --env HOME=/home/hermes --env HERMES_HOME=/home/hermes/.hermes --env PATH=/opt/ghostship/bin:/opt/hermes/venv/bin:/home/hermes/.local/bin:/home/hermes/.nix-profile/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ghostship-hermes /bin/sh -lc 'sed -n "1,220p" /home/hermes/.hermes/config.yaml'Expected config shape after first boot:
- Hermes home at
/home/hermes/.hermes terminal.backend: localterminal.cwd: /workspace- root model uses
custom:ollama-pro/deepseek-v4-pro:cloud fallback_modelusesopencode-go/deepseek-v4-proweb.backendisfirecrawlcustom_providersincludesollama-proathttps://ollama.com/v1and has no local router entry- Discord forced-channel behavior controlled by runtime env, not by hardcoding channel ids into
config.yaml
Local smoke:
tests/hermes-image/single-agent-dashboard.sh ghostship-hermes:devUseful live checks:
curl -fsS http://127.0.0.1:7681/api/status | jq
curl -fsS http://127.0.0.1:7681/terminal/ >/dev/null
docker exec ghostship-hermes sh -lc 'command -v nix git rg jq fd yq uv gh gws bw gcloud blogwatcher-cli agent-browser'
docker exec ghostship-hermes sh -lc 'test -d /home/hermes/.local/state/cloakbrowser && command -v google-chrome'
docker exec --user 3000:3000 --env HOME=/home/hermes --env HERMES_HOME=/home/hermes/.hermes --env PATH=/opt/ghostship/bin:/opt/hermes/venv/bin:/home/hermes/.local/bin:/home/hermes/.nix-profile/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ghostship-hermes /bin/sh -lc '/opt/hermes/venv/bin/hermes gateway status'
docker exec --user 3000:3000 --env HOME=/home/hermes --env HERMES_HOME=/home/hermes/.hermes --env PATH=/opt/ghostship/bin:/opt/hermes/venv/bin:/home/hermes/.local/bin:/home/hermes/.nix-profile/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ghostship-hermes /bin/sh -lc '/opt/hermes/venv/bin/hermes doctor'ci.ymlevaluates the flake for repo package wiring, runs focused Python tests, builds the Ubuntu Docker image, and runs the smoke test.publish-image.ymlbuilds and pushesamd64andarm64images frompackages/hermes-image/Dockerfileand then publishes the multi-arch manifest tags.mainpublisheslatest,sha-*, andhermes-*tags.- non-
mainmanual publish runs publish only immutablesha-*tags.
The old service-specific ghostship-* CLI layer and shared API wrapper platform are retired. Agents can create service-specific tools in persisted home or workspace state when they need them.
Current baked operator utilities:
blogwatcher-clibwfdgcloudghgitgwsjqrgtmuxttyduvyq
The image bakes native CloakBrowser into /opt/ghostship and exposes it as the standard google-chrome binary that agent-browser already probes on Linux, so Hermes keeps using the stock local Chrome lane without an executable-path override. The google-chrome wrapper injects CloakBrowser's default stealth args for normal launches, routes extension launches to the cached Chromium binary so Chrome reaches CDP startup, uses /home/hermes/.local/state/cloakbrowser when raw Chrome callers omit a profile, and preserves explicit agent-browser profile paths so native agent-browser --session isolation works as intended. The image pins npm agent-browser and installs a repo-patched native binary that humanizes local CDP mouse movement, click timing, wheel scrolling, and typing. A pinned unpacked uBlock Origin Lite is baked at /opt/ghostship/extensions/ublock-origin-lite, configured with complete filtering and the major default/privacy/security/annoyance rulesets, and loaded through AGENT_BROWSER_EXTENSIONS; AGENT_BROWSER_ARGS=--no-sandbox and DISPLAY=:99 are set for container Chrome launches, with Xvfb supervised in the image.
Bundled upstream Hermes skills are seeded into /home/hermes/.hermes/skills from the image on boot, but seeding is file-granular. Existing downstream custom skills are preserved, and only missing default skill files are added.
The image also syncs /home/hermes/ghostship-wiki from repo-owned Markdown at boot. Managed wiki files and the restored docs/api references are refreshed on image updates, while files outside .ghostship-managed-files are left for Hermes to maintain as its own working knowledge base.