Hermes + Kilo Code on a Hetzner VPS — Security-First Side-by-Side Install
The actually-tested install path for putting Hermes Agent (Nous Research) and Kilo Code CLI on the same modest VPS, isolated from each other, both pointed at OpenRouter. This is the version of this guide we wished we'd had on day one — every gotcha we hit during the install is documented inline.
- One Hetzner CX23 VPS (2 vCPU / 4 GB RAM / 40 GB disk, ~€4.49/month) running Ubuntu 24.04 LTS
- Two Linux user accounts,
hermesandkilo, each running its own coding agent. Neither hassudo. Neither can read the other's home directory. - Hermes Agent running as a per-user systemd service, listening to a private Discord bot, with persistent memory across sessions
- Kilo Code CLI invoked from the terminal (or driven by an editor over SSH)
- OpenRouter as the LLM provider for both — free-tier or paid, configurable per agent
- Zero new public ports. Only SSH (port 22) is exposed
If you only want one of these agents, stop after the relevant phase. The phases stack cleanly.
Why this architecture
A coding agent that writes files, runs scripts, and calls a model on your behalf is a powerful tool with a non-zero risk profile. The architecture below makes the blast radius of "the agent does something weird" as small as possible:
- Per-user isolation. Every agent runs as its own Linux user with mode 700 on its home directory. If Hermes goes off the rails, it cannot touch Kilo's files or vice versa. If either goes really off the rails, it cannot touch system files at all (no sudo).
- No sudo for agents. Both agents install entirely under their own home directories using user-scoped npm prefixes and Python virtualenvs.
- No new public ports. All agent control is outbound (Discord, OpenRouter) or via SSH. No code-server, no public web UI.
- Defense-in-depth firewall. Hetzner Cloud Firewall at the network edge plus UFW on the OS, both default-deny.
- Key-only SSH plus fail2ban plus unattended security updates.
- Backup root password set so the Hetzner web console is always usable as a recovery channel if SSH ever breaks.
If your threat model is higher than ours, layer on AppArmor profiles, full-disk encryption, and a separate jump box. The setup below is the floor, not the ceiling.
Prerequisites
- A Hetzner Cloud account and a CX23 (or larger) server running Ubuntu 24.04 LTS. Dashboard at console.hetzner.cloud.
- A Hetzner Cloud Firewall attached to the server, allowing inbound TCP 22 only. (Cloud → Firewalls → Create.)
- An SSH key pair on your local machine. Windows PowerShell ships with
ssh-keygen. Register the public key in Hetzner before server creation, or via the web console after. - An OpenRouter account with a few dollars of credit. Free tier is rate-limited to ~200 requests/day on unfunded accounts; depositing $10 raises the cap to ~1000/day, which is what an agent actually needs.
- A Discord account if you want to drive Hermes from chat (optional — Hermes also works from the CLI).
You do not need a domain name. Everything works IP-only. If you later want HTTPS or public APIs, layer Caddy/Cloudflare in front (out of scope here).
Phase 1 — Server hardening
Connect via PowerShell (better paste support than the Hetzner web console; keep the web console open in a tab as a recovery channel):
ssh [email protected]
Accept the host key fingerprint on first connect (yes). You'll land at a root@host:~# prompt.
1.1 Set a backup root password
The Hetzner web console asks for a username and password every time. If you only added an SSH key during server creation, root has no password and the web console is unusable until you set one:
passwd
Type a strong password (no characters appear as you type — that's normal, not a frozen terminal). Confirm it. Write the password down somewhere offline. You'll only use it when SSH is broken.
1.2 Update everything and install the security baseline
apt update && apt upgrade -y
apt autoremove -y
apt install -y unattended-upgrades fail2ban needrestart curl ca-certificates gnupg
If the upgrade pulls a new kernel (likely on a fresh image), needrestart warns about a pending reboot. We reboot at the end of the phase. If a dpkg prompt asks about sshd_config, choose keep the local version currently installed unless you know otherwise.
1.3 Enable automatic security updates
dpkg-reconfigure --priority=low unattended-upgrades
A blue dialog appears. Choose Yes. From this point Ubuntu will install security updates automatically.
1.4 Confirm fail2ban is protecting SSH
systemctl enable --now fail2ban
fail2ban-client status sshd
A fresh server will already show several banned IPs within minutes. That's normal background internet noise — it confirms the protection is working.
1.5 Enable UFW (defense in depth)
The Hetzner Cloud Firewall stops traffic at the network edge. UFW stops anything that gets through at the OS level. Two layers, default-deny:
ufw allow 22/tcp
ufw default deny incoming
ufw default allow outgoing
ufw enable
When ufw enable warns about disrupting existing SSH connections, type y. Your session won't drop because we explicitly allowed 22 first. Verify:
ufw status verbose
Expect: Status: active, default deny (incoming), allow (outgoing), with 22/tcp allowed both v4 and v6.
1.6 Harden SSH
Disable password authentication so brute-forcers can't succeed even if they pass fail2ban:
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sshd -t && systemctl reload ssh && echo "SSH reloaded OK"
The final line must print SSH reloaded OK. If sshd -t reports a syntax error, do not reboot, do not close the SSH session. Fix the file in place. The Hetzner web console (with the password from 1.1) is your fallback.
1.7 Stop SSH from timing out on long agent runs
Agents can run for tens of minutes between visible output. Without keepalives, your connection drops mid-job. Configure both ends.
Server side — append to /etc/ssh/sshd_config:
echo -e "\n# Keep idle SSH sessions alive\nClientAliveInterval 60\nClientAliveCountMax 10080" >> /etc/ssh/sshd_config
sshd -t && systemctl reload ssh && echo "SSH keepalive on"
The 10080 is minutes — about a week of idle tolerance.
Client side — on your local Windows machine, in PowerShell, edit (or create) $HOME\.ssh\config:
Host hetzner
HostName YOUR.SERVER.IP
User root
ServerAliveInterval 60
ServerAliveCountMax 10080
After saving, you can connect with ssh hetzner instead of typing the IP every time.
1.8 Reboot to load the new kernel
reboot
SSH drops. Wait 45–60 seconds, reconnect, verify:
uname -r # newer kernel
ufw status # still active
fail2ban-client status sshd
Phase 1 complete.
Phase 2 — Isolated agent users
Each agent gets its own Linux user with a mode-700 home directory and no sudo. They share system Node and Python but nothing user-writable.
adduser --disabled-password --gecos "" hermes
adduser --disabled-password --gecos "" kilo
chmod 700 /home/hermes /home/kilo
--disabled-password means there is no password; the only way into these accounts is sudo -iu hermes from root. The agents are never directly reachable from the public internet.
Verify isolation:
ls -ld /home/hermes /home/kilo
sudo -u kilo ls /home/hermes 2>&1
sudo -u hermes ls /home/kilo 2>&1
Both home directories should show drwx------. Both cross-user ls calls must print Permission denied. That denial is the security guarantee — even with full code execution as kilo, code cannot touch hermes's files.
Phase 3 — Shared dependencies
Installed once at the system level. Both agent users will use them.
apt install -y git build-essential python3.12 python3.12-venv python3-pip ripgrep ffmpeg tmux
build-essential is required up front because the Hermes installer will try to install build tools via sudo (which fails for a sudoless user). You want to be able to say "no" to that prompt and have the build still succeed.
Add Node.js 20 LTS (Kilo CLI requires Node ≥ 18; we use 20 LTS):
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
node -v && npm -v
Verify:
node -v # v20.x.x
npm -v # 10.x or 11.x
python3.12 --version
git --version
rg --version | head -1
ffmpeg -version | head -1
Phase 4 — Install Hermes Agent (as the hermes user)
Drop into the hermes user:
sudo -iu hermes
whoami # hermes
pwd # /home/hermes
4.1 Run the official installer
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
The installer clones the hermes-agent repo into ~/.hermes/hermes-agent/, creates a Python 3.11 virtualenv via uv, and installs dependencies. It will hit two points where the lack of sudo matters:
- Prompt: "Install build tools? [Y/n]" — answer n. The system already has
build-essential. Hermes installs fine without sudo. - Playwright tries to install browser system libraries via sudo — when it asks for the hermes user's (nonexistent) password, press Ctrl+C to abort that step. Hermes Python install completes. Finish Playwright separately as root, below.
4.2 Finish Playwright system libraries (in a second SSH window as root)
Open a second PowerShell window and SSH in as root, leaving your hermes session intact:
ssh hetzner # or ssh [email protected]
Then:
/home/hermes/.hermes/hermes-agent/node_modules/.bin/playwright install-deps chromium
This invokes the Node-based Playwright that the Hermes installer dropped, and lets it install Chromium-required system packages with root's permissions. About 30–60 seconds of apt install output.
4.3 Symlink the hermes binary if the installer was interrupted
If you Ctrl+C'd during Playwright, the installer may not have finished its PATH wiring. Back in your hermes window:
source ~/.bashrc
which hermes
If you see Command 'hermes' not found, the binary exists but is not yet on PATH:
ln -s /home/hermes/.hermes/hermes-agent/venv/bin/hermes ~/.local/bin/hermes
which hermes
hermes --version
You should now see something like:
Hermes Agent v0.11.0 (2026.4.23)
Project: /home/hermes/.hermes/hermes-agent
Python: 3.11.15
Phase 5 — Configure OpenRouter and the Hermes Discord gateway
This is the highest-friction part of the entire install. The deeper guide is at Discord Gateway: Setup, Troubleshooting & Channel Architecture. Summary here is enough to get you live.
5.1 Quick-setup the LLM provider
hermes setup
Choose Quick setup. When prompted:
- Provider:
openrouter - API key: paste your
sk-or-v1-...key (right-click in PowerShell to paste; key is hidden as you type, like a password) - Default model: for free, use
qwen/qwen3-coder-480b:free(best free agentic-coding model in April 2026),nvidia/nemotron-3-super-120b:free, ordeepseek/deepseek-r1:free. For paid,anthropic/claude-sonnet-4-6is the strongest single pick.
When the wizard finishes, answer n to "Launch hermes chat now?" — we still have the gateway to set up.
5.2 Add OpenRouter spend cap (recommended)
In OpenRouter dashboard → Settings, set a daily or monthly spend cap. This is your hard ceiling against an agent loop running away. Free models do not draw against the cap, but the cap raises your free-model rate limit when funded.
5.3 Set up the Discord gateway
hermes setup gateway
Toggle Discord with Spacebar (the wizard uses Space to select; Enter alone with no selection saves "no platforms"). Confirm with Enter. Paste your Discord bot token and your numeric Discord user ID when prompted. Skip the home channel for now.
For full Discord-side configuration (creating the bot in the Developer Portal, getting the token, the required intents, the auto_thread: false setting that fixes silent-failures, channel architecture, the systemd service install, lingering, and the XDG_RUNTIME_DIR fix) — read Discord Gateway: Setup, Troubleshooting & Channel Architecture. Skipping any of those steps is the fastest way to a non-functioning bot.
5.4 Verify
After gateway install + linger + service start (covered in the gateway guide), the bot should appear green in your Discord member list. Send @your-bot ping in any channel and confirm a reply within 30–60 seconds (free models are slow; this is normal).
Phase 6 — Install Kilo Code CLI (as the kilo user)
Exit the hermes session. From root, drop into the kilo user:
sudo -iu kilo
6.1 Configure a per-user npm prefix
This is what lets us install Kilo CLI globally without sudo — npm i -g writes to ~/.npm-global/ instead of /usr/lib/node_modules/:
mkdir -p ~/.npm-global
npm config set prefix "$HOME/.npm-global"
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
echo 'export XDG_RUNTIME_DIR=/run/user/$UID' >> ~/.bashrc
source ~/.bashrc
The XDG_RUNTIME_DIR line preempts the same systemd issue we hit with Hermes (see Phase 5 / gateway guide).
6.2 Install Kilo CLI
npm i -g @kilocode/cli
kilo --version
You should see version 7.2 or newer (April 2026).
6.3 Enable lingering for the kilo user
So any kilo systemd services or background processes survive SSH disconnects. From your root window:
loginctl enable-linger kilo
loginctl show-user kilo | grep Linger # expect: Linger=yes
Phase 7 — Configure OpenRouter for Kilo
In your kilo session:
mkdir -p ~/.config/kilo
cat > ~/.config/kilo/env <<'EOF'
KILO_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-v1-REPLACE_ME
OPENROUTER_MODEL=anthropic/claude-sonnet-4-6
EOF
chmod 600 ~/.config/kilo/env
echo 'set -a; source ~/.config/kilo/env; set +a' >> ~/.bashrc
source ~/.bashrc
The set -a; source ...; set +a pattern exports each line of the env file into the shell environment automatically on every login.
Verify by running an interactive Kilo session:
kilo
When it finishes its first-run setup, type a one-line task ("write a hello-world fastapi app and explain it") to confirm the model is reachable.
Phase 8 — Verification: confirm both agents are healthy and isolated
From root:
# Both home directories are private
ls -ld /home/hermes /home/kilo
# Cross-user reads are denied
sudo -u kilo ls /home/hermes 2>&1 # Permission denied
sudo -u hermes ls /home/kilo 2>&1 # Permission denied
# Secrets files are mode 600
sudo -u hermes stat -c '%a %n' /home/hermes/.hermes/.env
sudo -u kilo stat -c '%a %n' /home/kilo/.config/kilo/env
# No public ports beyond SSH
ufw status numbered
ss -tlnp | grep -v 127.0.0.1 # only sshd should listen on 0.0.0.0:22
# Hermes is running
sudo -u hermes XDG_RUNTIME_DIR=/run/user/$(id -u hermes) systemctl --user status hermes-gateway | head
# Kilo CLI works
sudo -u kilo bash -lc 'kilo --version'
Once all of those are clean, you have two independent autonomous coding agents on a single hardened VPS, each restricted to its own user, both pointed at OpenRouter, with no public web exposure beyond SSH.
Operational habits
Run agents inside tmux
Long agent runs survive SSH disconnects this way:
sudo -iu hermes
tmux new -s hermes
hermes
Detach without killing the run with Ctrl+B then D. Reattach with tmux attach -t hermes.
Two windows, always
Open two PowerShell windows from the start: one for the agent user, one for root. You'll need to flip back and forth — installs, service control, log tailing.
Watch live logs
journalctl --user -u hermes-gateway -f # as the hermes user
Hermes' default log level only emits warnings and errors. To see message receipt and processing, edit ~/.hermes/config.yaml and bump log level to INFO or DEBUG, then restart the gateway.
Rotate keys quarterly
OpenRouter and Discord bot tokens both have rotation flows. Set yourself a quarterly calendar reminder. Both updates are a single edit in /home/hermes/.hermes/.env followed by systemctl --user restart hermes-gateway.
Dispose-and-rebuild is cheap
If anything goes sideways: deluser --remove-home hermes && deluser --remove-home kilo, recreate the users, re-run Phases 4–7. Total time: ~20 minutes. The system phases (1–3) survive untouched. This is a feature of the architecture, not a bug.
Where to read more
- Discord Gateway: Setup, Troubleshooting & Channel Architecture — the deep dive on Discord setup, the
auto_threadtrap, channel-permission overrides, systemd lingering, the bus-not-found error. - Hermes + Kilo Code Troubleshooting & FAQ — every error message we hit while writing this, with the fix.
If something here is wrong or out of date, the fastest sanity check is hermes --version on the box itself — Hermes ships a built-in update checker. Same for kilo --version.
← Back to Hermes hub · Next: Discord Gateway →