It started with Openclaw
I had a spare MacBook Air lying around.
When Openclaw (an open-source AI agent you run on your own machine) started getting attention, I wanted to put it to use as a developer, so I installed it on that spare Air and let it run. I poked at it for a while, but never really got much out of it — in the end it settled into churning out a daily econ/tech report and some light calendar wrangling.
It slowly faded from memory, until a side project brought it back. Building features is the easy part; what ate my time was everything around it — writing things down, promoting them, and producing promo material on top of that. I decided to automate that grind, and that’s when I came across Hermes Agent — an AI agent that runs tools and scripts on your behalf to carry out predefined jobs (cleaning up posts, generating promo assets, and so on).
The MacBook Air is back in family hands now, so I looked at a VPS instead and went with Hostinger because it was cheap.
If I was going to keep a server running anyway, I wanted it to be both:
- an always-on home for AI agents, and
- a remote dev box I can reach from anywhere.
The trick was cramming both onto one server without letting them kill each other. And to reach it from outside, the moment you open a port you become scanner bait — so I needed a way to expose everything with zero open ports.
So the goals were three:
- Run the Hermes agent isolated.
- Bolt remote development via Claude Code right next to it — from my phone too.
- Expose both with no open ports, and nobody uninvited gets in.
The big picture
Here’s the finished shape first.
Internet
│
Cloudflare Zero Trust ← identity check here (email OTP)
│
Cloudflare Tunnel (outbound connection, 0 inbound ports)
│
┌───────────────┴───────────────┐
▼ ▼
sub1.example.com sub2.example.com
→ 127.0.0.1:4860 → 127.0.0.1:7681
│ │
┌──────┴────────┐ ┌───────┴────────┐
│ Hermes │ │ ttyd web term │ ← host level
│ container │ │ → Claude Code │
│ (isolated) │ │ │
└───────────────┘ └────────────────┘
Two ideas carry the whole design. Only Hermes lives inside a container, while the management tools (web terminal + Claude Code) live on the host. And all external exposure happens only through a Cloudflare Tunnel, with Zero Trust standing guard in front of it.
Let me unpack why I split it this way.
1. Lock Hermes inside a container
I ran the Hermes agent as a Docker container. An agent is something that runs tools freely, so sharing a process space with the host felt risky. Putting it in a container isolates the filesystem, processes, and network — whatever the agent does, it does inside that box.
I didn’t open any ports to the outside either. It binds to loopback (127.0.0.1)
only.
# docker-compose.yml (excerpt)
services:
hermes-agent:
image: ghcr.io/.../hermes-agent:latest
restart: unless-stopped
ports:
- "127.0.0.1:4860:4860" # not external, local-only
volumes:
- ./data:/opt/data # persist config/state
# hardening: the agent runs untrusted-ish tool calls,
# so I tightened the blast radius
mem_limit: 2g # cap memory so a runaway agent can't OOM the host
pids_limit: 512 # cap process count against fork bombs
security_opt:
- no-new-privileges:true # block setuid privilege escalation
cap_drop:
- ALL # drop every Linux capability, add back only if needed
Because it’s 127.0.0.1:4860, the port may as well not exist from outside the host.
Pushing it outward is entirely the Cloudflare Tunnel’s job, behind the scenes.
The 127.0.0.1: prefix is the part that matters. Docker has a well-known trap: if you
map a port without an IP, like 4860:4860, it binds to 0.0.0.0 (all interfaces) and
bypasses firewall rules such as UFW by writing its own iptables rules. So I
deliberately prefixed 127.0.0.1: to pin it to loopback.
The mem_limit is doing double duty here. It’s not just hardening — it’s also what
makes the next decision pay off: when the agent overruns its memory, the OOM killer
takes down the container, not the host. That containment is exactly what keeps the
recovery path (next section) alive.
Note: many VPS providers now ship Docker / AI-agent templates out of the box, so standing up a setup like this is much easier than it used to be.
2. The decision that mattered most — keep the management tools outside the container
At first I considered putting the web terminal and Claude Code inside the Hermes container too. One place, less to manage. But one scenario stopped me:
What happens when Hermes gets OOM-killed, or the whole container falls over?
If the management tools lived in the same container, the very door I’d use to fix it dies with it. Exactly when I need emergency recovery, I’d have no way in. This is just the old rule: don’t bundle the thing you manage with the channel you manage it through. (The networking world calls it keeping the management plane separate from the data plane.)
So I set a principle:
- Container = the Hermes agent only. (one container = one responsibility)
- Host = the management tools (web terminal, Claude Code,
docker/systemdcontrol).
With that split, no matter what state Hermes lands in, I can drop into the host’s web
terminal and run docker to revive it or read its logs. The management channel
outlives the thing it manages.
3. The remote-dev path — ttyd web terminal + Claude Code
On the host I ran ttyd. ttyd is a tiny program that serves a terminal as a web page. With nothing but a browser — laptop or phone — I can get into the server shell from anywhere.
I registered it as a systemd service so it’s always up, and again I bind it to loopback only.
# /etc/systemd/system/ttyd-host.service (excerpt)
[Service]
ExecStart=/usr/local/bin/ttyd-host-start.sh
Restart=always
# run as a normal user, not root — the shell is reachable over the web,
# so I don't want it handing out a root prompt
User=deploy
The heart of that ttyd-host-start.sh script is really just one line:
# bind ttyd to 127.0.0.1:7681 only (the tunnel handles external exposure)
ttyd --interface 127.0.0.1 --port 7681 bash
One thing to be clear about: ttyd ... bash opens a server shell with no
authentication at all. The endpoint is wide open by itself, and the only line of
defense is the Zero Trust Access I’ll describe in step 5. If Access falls, the shell
falls — so step 5 isn’t optional, it’s mandatory. (You can layer ttyd --credential user:pass on top for basic auth as defense-in-depth, but I leaned on Access as the
real gate.)
Fire up Claude Code inside that shell and a single browser tab becomes a remote dev
environment. I can open sub2.example.com on my phone and tell Claude Code “fix this.”
(This very post, in fact, was first drafted that way.)
4. Exposing it without opening a port — Cloudflare Tunnel
Both services so far live only on 127.0.0.1. Nothing outside can reach them. The usual
next step is to open a firewall port and stand up a reverse proxy — and from that moment
on, bots from all over the planet start knocking on it.
Instead I used Cloudflare Tunnel. The mechanism is clever:
- On the server,
cloudflaredmakes an outbound connection to Cloudflare. - External requests arrive at Cloudflare, then ride that existing connection down to the server.
- So the server has zero inbound open ports. There’s no hole in the firewall.
Because the connection is outbound, the tunnel also traverses NAT and CGNAT happily — which is what makes this work from a home box just as well as a VPS. I ran it as a service so it survives reboots:
cloudflared service install # register cloudflared as a systemd service
The config is just a mapping of which hostname goes to which local port:
# cloudflared config (excerpt)
tunnel: <tunnel-id>
ingress:
- hostname: sub1.example.com
service: http://127.0.0.1:4860 # Hermes UI
- hostname: sub2.example.com
service: http://127.0.0.1:7681 # web terminal
- service: http_status:404 # catch-all: refuse anything unmapped
Now sub1.example.com reaches the Hermes UI and sub2.example.com reaches the web
terminal — with not a single server port open. (The trailing http_status:404 is
required: it’s the catch-all rule so any hostname you didn’t explicitly map gets
refused rather than leaking somewhere.)
5. The final lock — Zero Trust Access
The tunnel is up, which means anyone with the address can now walk up to it. And
sub2.example.com in particular is a door wired straight to a server shell —
leaving it open without auth would be reckless.
So I put Cloudflare Zero Trust Access in front of the tunnel. Before a request ever touches my server, Cloudflare’s edge checks identity first.
- Policy: “allow my email only.”
- On first visit, Cloudflare emails a one-time PIN (OTP).
- You enter the code to pass, and only then does the tunnel open.
I don’t have to write any auth logic on the server. Authentication ends at the Cloudflare edge, and only requests that pass come down to me. It’s an extra identity gate sitting in front of a shell-wired endpoint.
A practical note for the agent half: interactive OTP is great for me in a browser, but useless for machine-to-machine calls. For anything non-interactive hitting Hermes (a cron job, a webhook), Access supports service tokens — a client ID/secret pair checked at the same edge — so automation gets through the gate without a human typing a code.
This blog actually uses the same pattern. Private posts under
/private/*are locked behind Cloudflare Access, and only authenticated-me can read them. Learn it once and you reuse it everywhere.
Wrapping up
To sum it all up:
- Hermes is isolated in a container — whatever the agent does, it stays in the box.
- Management tools live on the host — a recovery path that survives the container dying.
- Cloudflare Tunnel — external exposure with zero inbound ports.
- Zero Trust Access — locked at the edge with email auth, in front of the tunnel.
Thanks to all that, one VPS became both an always-awake AI agent and a dev box I can reach from my phone — with the count of externally open ports staying at zero the whole way through.
As a bonus, I’m mulling spinning up a second Hermes container for the family, or splitting things out per-person with separate profiles. Hoping I actually make better use of it this time.
Comments
Loading comments...
Leave a comment
Your comment will be published after admin approval.