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
├── 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
├── 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 |
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 |
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_TZ=America/Los_Angeles |
set timezone (default: UTC) |
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) |
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. hermes setup— configure your model + provider.hermes doctor— sanity-check the install.hermes gateway setup— wire up Telegram / Discord / Slack / …sudo tailscale up— bring this node onto your tailnet.
- 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.
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 maintmux 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, 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. hermes-do1, mac-mini, hetzner-builder)
empty = keep 'ubuntu-s-...', no rename
> hermes-do1
→ will rename to: hermes-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.