Turn a fresh Ubuntu / Debian VPS (or a new Mac) into a fully-loaded Hermes Agent host in one command.
curl -fsSL https://raw.githubusercontent.com/dwhly/hermes-host-bootstrap/main/bootstrap.sh \
| bash -s -- --tier=recommendedOr clone first if you want to read the script before running it (recommended):
git clone https://github.com/dwhly/hermes-host-bootstrap.git
cd hermes-host-bootstrap
./bootstrap.sh --tier=recommendedA fresh DigitalOcean / Hostinger / Hetzner Linux box is missing a lot of the daily-driver tooling an AI agent (and the human running it) actually needs: a multiplexer, modern unix CLIs, a real Python/Node toolchain, container runtime, media tooling, security baseline, and Hermes itself.
bootstrap.sh is one idempotent script that installs all of that, in
tiers, with sane defaults. Re-running it is safe — every step checks
before it acts.
The script also asks what is this machine for? — because a VPS and your Mac want very different software.
| Role | Auto-detected when… | Skips | Adds |
|---|---|---|---|
server |
Linux, no $DISPLAY |
Ghostty, MS Remote Desktop, mac apps | xrdp/XFCE (with --tier=full) |
client |
macOS | xrdp, server-side daemons | Ghostty, MS Remote Desktop, Tailscale GUI |
both |
Linux with a desktop | nothing | both sides |
Override with --role=server|client|both if auto-detect gets it wrong.
Examples:
# Fresh VPS — server bits only (auto-detected)
./bootstrap.sh --tier=recommended
# Fresh VPS but you want a remote desktop too
./bootstrap.sh --tier=full # xrdp + XFCE included
# New Mac — client bits only (auto-detected)
./bootstrap.sh --tier=recommended
# Desktop Linux you use as your daily driver AND as a Hermes host
./bootstrap.sh --tier=full --role=both| Tier | What you get | When to use |
|---|---|---|
minimal |
25 items — Hermes runs, ssh is locked down, tmux works | small VPS, tight RAM, you'll add tooling on demand |
recommended |
+ zsh/omz, mosh, docker, tailscale, full media stack, Claude Code + Codex | default — what most Hermes hosts should look like |
full |
+ small "nice-to-have" CLIs (tldr, tree, httpie, glances, etc.) | when disk is cheap |
Manifests: tiers/minimal.txt ·
tiers/recommended.txt ·
tiers/full.txt
The work is split across prefixed modules in lib/. Numeric prefixes
run in order (00 → 90), then letter-prefixed modules run last
(A0, M5, …):
lib/
├── common.sh shared helpers (logging, apt_install, ensure_line,
│ tier_allows, role_includes, …)
├── 00-preflight.sh hostname, tz, apt upgrade, swap, enable-linger
├── 05-ssh-access.sh opt-in authorized_keys + macOS Remote Login helpers
├── 10-security.sh openssh, ufw, fail2ban, unattended-upgrades, ssh hardening
├── 20-buildchain.sh build-essential + libs (so pip/cargo wheels compile)
├── 30-shell.sh tmux, zsh, oh-my-zsh, neovim, mosh, micro
├── 40-cli.sh rg, fd, fzf, bat, jq, htop, btop, eza, zoxide, delta, …
├── 50-languages.sh python + uv + pipx, fnm + Node LTS, Rust
├── 60-containers.sh Docker + compose, user added to docker group
├── 70-network.sh Tailscale, cloudflared
├── 80-media.sh ffmpeg, imagemagick, poppler, tesseract, pandoc, espeak-ng
├── 90-agents.sh hermes, gh, Claude Code CLI, Codex CLI, faster-whisper
├── 92-hermes-config.sh personal ~/.hermes clone/pull, op inject, model config seed
├── 93-hermes-wiki.sh local Hermes Automation Wiki clone/pull + HTML publish
├── 95-ghostty.sh Ghostty terminal — client/both role only
├── A0-remote-desktop.sh xrdp + XFCE (opt-in: --tier=full or --only=A0-remote-desktop)
└── M5-mac-client.sh tmux + mosh + MS Remote Desktop + Tailscale GUI (macOS client only)
Each module can also be run on its own:
./bootstrap.sh --only=90-agents,60-containers--tier=<minimal|recommended|full> default: recommended
--skip=KEY1,KEY2,... skip specific items (e.g. docker, zsh, ghostty)
--only=MOD1,MOD2,... run only these modules
--dry-run print the plan, don't execute
--self-update git pull && re-exec
| Key | Skips |
|---|---|
apt-upgrade |
the initial apt-get upgrade |
swap |
swap file creation |
linger |
loginctl enable-linger |
authorized-keys |
skip ~/.ssh/authorized_keys management from HERMES_AUTHORIZED_KEYS(_FILE) |
mac-remote-login |
macOS only: skip Remote Login enablement when HERMES_MAC_REMOTE_LOGIN=1 |
ssh-harden |
sshd_config edits (off by default — only fires if HERMES_SSH_HARDEN=1) |
ufw |
ufw install + rules |
fail2ban |
fail2ban |
unattended |
unattended-upgrades |
tmux / tmux-conf |
tmux package / .tmux.conf install |
tmux-workspace-colors |
per-session statusbar coloring for hermes-workspace panes (ops/code/logs/scratch) |
tmux-autoattach |
auto-attach to tmux session "main" on interactive SSH (snippet sourced from .bashrc/.zshrc) |
hssh |
hssh [session] [host] shell function: ssh + attach/create named tmux session; defaults to session main and reads default_user: from ~/.hermes/hosts/<host>.yaml |
hostname-rewrite |
rewrite stale long hostnames in user's HSSH_DEFAULT_HOST exports after fleet renames (table lives in lib/30-shell.sh) |
aliases |
shell aliases (h, hm, hmreset, ...) — managed by the add-shell-alias skill |
chsh-zsh |
make zsh the default login shell on Linux hosts (matches macOS default) |
mac-hostname |
macOS only: rename HostName + LocalHostName to $HERMES_MAC_HOSTNAME (opt-in) |
op |
install the 1Password CLI (for secrets resolution via .env.template) |
op-resolve |
resolve ~/.hermes/.env.template → ~/.hermes/.env via op inject on each bootstrap run |
hermes-wiki / hermes-wiki-pull / hermes-wiki-build |
local Hermes Automation Wiki clone/pull/build |
ghostty-workspace |
macOS only: 2x2 Ghostty grid launcher (hermes-workspace) via AppleScript |
mosh-firewall |
UFW rule for mosh UDP 60000-61000 (rule still added if ufw stays disabled) |
zsh / oh-my-zsh |
zsh / OMZ |
inputrc |
.inputrc install |
mosh, neovim, micro |
per-tool |
python, uv, node, rust, pnpm |
language runtimes |
docker |
Docker engine |
tailscale, cloudflared |
network tools |
media |
ffmpeg + imagemagick + poppler + tesseract + pandoc |
hermes, gh, claude-code, codex, faster-whisper, browser-deps |
agent layer |
ghostty |
Ghostty terminal |
| Var | Effect |
|---|---|
HERMES_HOSTNAME=mybox |
set the hostname during preflight |
HERMES_HOST_DEFAULT_USER=root |
set default_user: in the host registry for hssh/user@host resolution |
HERMES_TZ=America/Los_Angeles |
set timezone (default: UTC) |
HERMES_AUTHORIZED_KEYS |
newline-separated public SSH keys to ensure in ~/.ssh/authorized_keys |
HERMES_AUTHORIZED_KEYS_FILE |
file of public SSH keys to ensure in ~/.ssh/authorized_keys |
HERMES_MAC_REMOTE_LOGIN=1 |
macOS only: enable Remote Login for SSH and allow the current user |
HERMES_SSH_HARDEN=1 |
actually apply ssh hardening (off by default to avoid lockout) |
HERMES_UFW_ENABLE=1 |
actually enable ufw (off by default to avoid lockout) |
HERMES_CONFIG_REPO=git@github.com:USER/hermes-config.git |
clone/pull personal ~/.hermes config during bootstrap |
HERMES_WIKI_REPO=git@github.com:USER/hermes-automation-wiki.git |
clone/pull local wiki during bootstrap |
HERMES_WIKI_DIR=~/code/hermes-automation-wiki |
override local wiki checkout path |
HERMES_MODEL_PROVIDER=openrouter |
non-interactively seed model.provider so hermes setup is unnecessary |
HERMES_MODEL_DEFAULT=openai/gpt-5.5 |
non-interactively seed model.default |
HERMES_GATEWAY_INSTALL=1 / HERMES_GATEWAY_START=1 |
install/start gateway after config + secrets are in place |
Ghostty is a GUI terminal emulator. It needs a display server, which
a headless VPS does not have. The 95-ghostty.sh module detects this
and skips with a friendly notice on headless boxes. It runs the real
install on:
- macOS (via
brew install --cask ghostty) — for your client Mac - Desktop Linux (via snap or flatpak)
- Fedora (via the pgdev/ghostty COPR)
So you can also point this script at a new Mac to set up your client
machine. Pass --skip=docker,tailscale if you don't want a heavy
client-side install.
Everything in lib/common.sh is designed so re-running the script is
safe:
apt_installfilters out already-installed packagesensure_lineonly appends if the line isn't already in the filebackup_onceonly backs up if no.bak.<date>already exists- Each module checks
have X/[[ -d X ]]before doing work apt-get updateruns at most once per bootstrap invocation
The full log is teed to ~/.hermes-host-bootstrap.log so you can audit
what happened.
The script prints a checklist at the end. The short version:
- Log out and back in — so
PATH, thedockergroup, andlingeractually take effect. - If
HERMES_CONFIG_REPO/HERMES_MODEL_*are set in~/.hermes-bootstrap.conf, Hermes model/provider config is already seeded; nohermes setupprovider picker is needed. hermes doctor— sanity-check the install.hermes gateway setuponly if you did not preseed gateway/env config.sudo tailscale up— bring this node onto your tailnet.
On a new Mac, the first Homebrew install needs a real sudo prompt. If you are
running over SSH and see Need sudo access on macOS, rerun from Terminal.app
on the Mac, or use:
hermes-reload --interactive-sudo --prompt --role=bothThe --interactive-sudo mode re-enters through ssh -tt localhost when
possible so sudo/Homebrew can prompt instead of half-running later modules.
- Ubuntu 24.04 LTS (DigitalOcean, Hetzner)
- Debian 12 (mostly — Ghostty falls back to flatpak)
- macOS 14 (Sonoma) and 15 (Sequoia)
Probably works on Ubuntu 22.04 too. PRs welcome for Fedora / Arch / WSL.
A headless VPS doesn't have a graphical desktop by default. If you want
to ssh in and be able to run GUI apps (browser dev tools, an IDE,
whatever) on the box itself, opt into the A0-remote-desktop module:
# Either way enables it:
./bootstrap.sh --tier=full
./bootstrap.sh --only=A0-remote-desktop # standalone
HERMES_RDP=1 ./bootstrap.sh # any tierWhat you get:
- XFCE as the desktop (lightweight, ~150 MB)
- xrdp as the protocol server, bound to 127.0.0.1
You connect from your Mac in one of two ways:
# Option A — ssh tunnel
ssh -L 3389:localhost:3389 you@vps
# then open Windows App / Microsoft Remote Desktop → connect to localhost:3389
# Option B — Tailscale
tailscale serve --tcp=3389 tcp://localhost:3389 # one-time
# then connect to your VPS's tailscale name on port 3389Microsoft Remote Desktop (free, App Store; renamed "Windows App" in
late 2024) is the recommended Mac client. The M5-mac-client module
installs it automatically when --role=client or --role=both.
To expose xrdp publicly anyway (not recommended), set
HERMES_RDP_PUBLIC=1. You'll want ufw + fail2ban configured first.
The repo itself is opinionated about what a Hermes host looks like
(packages, hardening, verification) but neutral about who you are.
If you want to set personal defaults — your preferred tier, your git
identity for commits the script makes, your timezone, your lockout-risk
overrides — drop them in ~/.hermes-bootstrap.conf and bootstrap.sh
will source it on each run.
cp .hermes-bootstrap.conf.example ~/.hermes-bootstrap.conf
$EDITOR ~/.hermes-bootstrap.confSee .hermes-bootstrap.conf.example for the full set of variables.
This keeps the repo cleanly forkable — a new user clones, drops their
own .hermes-bootstrap.conf, and never has to touch the repo itself.
A new fleet host does not just get packages — it inherits the fleet's
governance wiring automatically, via the synced ~/.hermes config repo
(92-hermes-config.sh clones/pulls HERMES_CONFIG_REPO). Two whitelisted
paths in that repo carry it:
memories/MEMORY.md— injected into every agent turn. Holds the constitutional pointer: "Chief work is governed by playbooks + the wiki; load thechief-playbooksskill first; classify station-vs-direct and state it." This is the only always-on channel, so it holds pointers + a few reflexes, never process prose.- agent-authored
skills/(e.g.chief-playbooks,hermes-fleet-maintenance) — the loaders that read the live source-of-truth docs before acting.
So provisioning a host = it inherits the same prime directives the rest of the
fleet runs under, no per-host setup. The authoritative model — source of
truth (Chief docs/ playbooks + wiki) → loader (Hermes skills) → reflex
(memory), plus the promotion path and the station-vs-direct directive — lives
in the Chief umbrella at docs/playbooks/governing-knowledge-and-process.md
(this README only points at it; one source of truth).
Known gap (memory is fleet-global, undifferentiated — by accident, not design). Because
memories/is whitelisted in the config repo's.gitignore, all memory propagates to every host onsync.shpull. That is correct for universal facts (who the user is, the constitution) but wrong for host-specific ones ("this box is RAM-tight") which get broadcast to every machine as if universal. There is no universal-vs-host-specific split today; the fleet-global behaviour is an emergent property of one.gitignoreline, not a declared policy. Tightening that whitelist would silently make memory host-local and nobody would notice until two boxes disagreed. Tracked as future work ingoverning-knowledge-and-process.md§8.
The default ssh session from a Mac dies the moment the laptop sleeps —
Wi-Fi drops, the TCP socket goes stale, and when you wake up you get
No route to host / Broken pipe / client_loop: send disconnect. The
fix is two tools, used together:
- mosh replaces ssh for the transport. It uses UDP, so it survives
sleep, Wi-Fi changes, and IP roaming. Auth still goes through ssh (mosh
shells out to it under the hood), so your keys and
~/.ssh/configaliases still work. - tmux runs on the server and protects the running process state. Even if mosh itself dies, your shell + whatever it was running keeps going inside the tmux session; you reattach and you're back.
Both are installed by this bootstrap:
| Side | Where it's installed | Notes |
|---|---|---|
| Linux server | lib/30-shell.sh (tier R) |
tmux + ~/.tmux.conf, mosh package |
| macOS client | lib/M5-mac-client.sh (role=client/both) |
tmux + mosh via Homebrew |
| Firewall | lib/10-security.sh |
ufw allow 60000:61000/udp added even when ufw stays off |
Daily usage from the Mac:
mosh you@vps -- tmux new -A -s mainor, via the bundled helper after the host registry has synced:
hssh h-do1 # session main on h-do1, using hosts/h-do1.yaml default_user
hssh code h-do1 # session code on h-do1tmux new -A -s main creates a session named main if missing, or
attaches to it if it exists. Detach with Ctrl-b d (or Ctrl-a d if
you're using the bundled ~/.tmux.conf, which rebinds prefix to
Ctrl-a). Re-run the same command tomorrow and you pick up exactly
where you left off.
Cloud-provider firewalls (DigitalOcean, Hetzner, etc.) usually need their
own rule — open UDP 60000-61000 in the provider's web console too,
not just ufw.
Every bootstrap run drops a yaml snapshot of the host at
~/.hermes/hosts/<hostname>.yaml — OS, role, tier, IPs, tool versions,
resource ceiling, install date, a default_user: for SSH helpers, and a
free-form note: describing what the box is for. If ~/.hermes is
git-tracked via the companion hermes-config-sync repo, those snapshots
roll forward to every other machine, giving you a portable inventory.
When bootstrap.sh runs in a real terminal (stdin is a TTY) and
HERMES_HOSTNAME isn't already set, it asks two short questions
before installing anything:
Hostname for this machine in the Hermes fleet.
current OS hostname: ubuntu-s-1vcpu-2gb-70gb-intel-sfo2
pick a short, unique name (e.g. h-do1, h-mini, hetzner-builder)
empty = keep 'ubuntu-s-...', no rename
> h-do1
→ will rename to: h-do1
One-line note — what is this machine for?
e.g. 'main production VPS for hermes', 'macbook M2 daily driver', 'hetzner build farm'
empty = skip (registry will have no note for this host)
> main Hermes host, SFO, gateway + agent
Existing fleet hostnames are listed first so you don't pick a collision.
Both answers are optional — empty input keeps the current value. Skip
the prompts entirely with HERMES_NONINTERACTIVE=1 or by pre-setting
HERMES_HOSTNAME / HERMES_HOST_NOTE (in ~/.hermes-bootstrap.conf
or the environment).
Tip: if you rename the box at the OS level, also rename it in your cloud provider's web console (DigitalOcean droplet name, Hetzner server name, etc.) so the dashboards and billing line up.
The bundled hermes-fleet script (installed to ~/.local/bin/) reads
the registry and prints a one-screen dashboard:
$ hermes-fleet
Hermes Fleet (3 hosts in registry)
HOST TIER ROLE OS HERMES DISK NOTE
hermes-do1 recommended server Ubuntu 24.04.4 LTS 0.14.0 18% main Hermes host, SFO, gateway + agent
hetzner-builder full server Debian 12 0.18.1 72% background build farm + cache mirror
mac-mini recommended both macOS 15.2 0.18.2 58% M2 daily driver
Detail for one host: hermes-fleet <hostname>
Live SSH probe: hermes-fleet --live
Refresh this box: hermes-fleet --refresh
Modes:
hermes-fleet— static dashboard from snapshot fileshermes-fleet <hostname>— dump the full yaml for one hosthermes-fleet --live— also SSH into each reachable host for current uptime + live disk (usesBatchMode=yes, so it only works for hosts with passwordless key auth from this box, e.g. via Tailscale)hermes-fleet --refresh— rewrite this host's entry (re-runs the99-register-hostmodule)hermes-fleet --json— machine-readable output for scripting
To keep snapshots current between bootstrap runs (e.g. so disk and uptime are fresh on the dashboard), wire a cron job on each host:
# crontab -e
0 * * * * cd ~/path/to/hermes-host-bootstrap && ./bootstrap.sh --only=99-register-host >/dev/null 2>&1 && ~/.hermes/sync.sh save "hourly host snapshot" >/dev/null 2>&1MIT. Use it however you like.