Skip to content
Piyak Labs
Go back

Turning one VPS into an AI agent home + a dev box I can reach from anywhere

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:

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:

  1. Run the Hermes agent isolated.
  2. Bolt remote development via Claude Code right next to it — from my phone too.
  3. 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:

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:

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.

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:

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.



Previous Post
Building K-Saju 1: turning Korean saju into K-culture character cards
Next Post
Login-free comments, built by hand on Cloudflare

Comments

Loading comments...

Leave a comment

Your comment will be published after admin approval.