← Back to writing

Turning an old Intel Mac mini into a personal agent server

May 18, 2026

Turning an old Intel Mac mini into a personal agent server

There's a 2018 Mac mini that's been sitting on a shelf at home for a while. Intel i3, four cores, the kind of machine that gets quietly skipped over in 2026 because it can't run Apple's local model demos and won't see another major macOS upgrade. It still has its uses though, and I wanted to figure out what they were.

The plan was simple in the head and longer than expected in practice: set it up as a headless box I can reach from anywhere, lock it down enough that I'm not nervous about it, and get Claude Code running so I can start using it for personal agent work. Eventually I want to run Hermes there too, but first the foundation.

This is what the path actually looked like, the dead-ends included.

Why this old Mac mini still makes sense

Most of the Mac mini hype right now is about Apple Silicon and local LLMs. M4 Pro, 48GB unified memory, run 30B parameter models on your bookshelf. That's the conversation. And it's a fair one, but it's also a different machine than what I have.

What I actually want is a small always-on box that runs a few personal agents hitting cloud APIs. Claude, OpenAI, that kind of thing. Some scheduled scripts. A Telegram bot maybe. None of this needs Apple Silicon performance, none of it needs the unified memory advantage, none of it needs more than a couple of cores. The i3 with whatever RAM it has is plenty.

The bigger constraint is that this Mac dropped off the supported list for macOS 26. So Sequoia is the last major OS it gets, which is fine because everything I want to run works on Sequoia today. The machine has a few years of useful life left if I treat it well.

The user account question

I started with a fork in the road that turned out to be more important than it looked. The mini already had my personal account on it, signed into iCloud. The question was whether to use that for agent work or create a separate user.

I went with separate. Created an account called max, kept my personal ickas account around as the admin, didn't sign max into iCloud at all. The reasoning: agents that can read Apple Notes, send iMessages, and trigger Shortcuts are powerful and slightly terrifying. The selling point of tools like Hermes or OpenClaw is system-level integration. I don't want a runaway agent iMessaging my family or reading my Keychain. Different user, no shared state, problem contained.

The trade-off shows up immediately. max as a standard non-admin user means no sudo, which means a lot of system configuration has to happen as ickas. I went back and forth on this in the setup and eventually settled on a workflow with two SSH sessions: max for daily work, ickas for system tasks. More on that below.

FileVault, and the reboot problem

FileVault is macOS full-disk encryption. It's on by default on new Macs, and for headless setups it creates one specific problem: after a reboot, the disk needs to be unlocked at a pre-boot password screen, and that screen runs before networking comes up. No network means no SSH. No SSH means the Mac is stuck until someone physically types the password.

For a "personal Mac in a closet at home" threat model, FileVault is mostly protecting against physical theft. Realistically I'm not worried about that, and the reboot brittleness is real: a power blip at 3am means the mini is offline until I notice and walk over to unlock it.

I kept FileVault on anyway. Partly because my personal account still lives on the box and I have iCloud Keychain stuff in there. Partly because the inconvenience of an unplanned reboot is rare and I'd rather have encryption than not. The trade is honest: planned reboots need me to be present, power outages mean downtime until I'm home. I can live with that.

If you're setting up something similar and your only account is the agent user with no personal iCloud, the calculus flips. There's not much on the disk worth encrypting beyond OAuth tokens (which live in the Keychain anyway), and the reboot tax stops being worth it.

Power settings that actually matter

This is the part everyone skips and regrets. Macs are configured by default to sleep, save power, dim displays. None of that makes sense for a server.

sudo pmset -a sleep 0 disksleep 0 displaysleep 10 womp 1 autorestart 1 powernap 0

Translating: never sleep, never spin down disks, dim the (non-existent) display after ten minutes, wake when network packets arrive, restart automatically after a power failure, turn off Power Nap. The womp flag is the one people forget and the one that bites them when they can't reach the Mac after it suspends.

There's a subtle gotcha here. The first time I ran this, some values didn't stick. disksleep came back as 10 instead of 0. Running it again on the admin account fixed it. I assume there's a permission interaction I don't fully understand. If something looks wrong after the first apply, just run it again and verify with pmset -g.

SSH access from outside the house

Local SSH is easy. Enable Remote Login in Sharing settings, restrict it to the users you want, you're done. ssh max@max-mini.local from any Mac on the home network.

The harder problem is reaching the mini from outside. There are three real options.

The first is Tailscale, a mesh VPN built on WireGuard. Install on both Macs, log in, they see each other through a private virtual network from anywhere. Five minutes of work, no router configuration, both SSH and Screen Sharing work natively. This is the answer most self-hosters give, and they're right.

The second is port forwarding on the router. Open 22, point it at the mini, deal with your home IP changing via dynamic DNS. It works but you're exposing SSH to the public internet, which means SSH password authentication has to go off immediately and you need keys. Bots will probe the port within minutes of you opening it.

The third is Cloudflare Tunnel. The mini runs a small cloudflared daemon that maintains an outbound connection to Cloudflare. When you SSH from anywhere, Cloudflare proxies you back through that connection. No public ports, no router configuration, no dynamic DNS. You need a domain on Cloudflare, which I have.

I went with Cloudflare Tunnel. Tailscale is probably the more pragmatic pick (especially because Screen Sharing over Cloudflare is fiddly), but I already have my domain set up there, I like that I can put Cloudflare Access SSO in front of it later, and the "install another app on every device I might use" friction of Tailscale was real for me. Different mental models. Both work.

The basic flow:

brew install cloudflared
cloudflared tunnel login
cloudflared tunnel create max-mini
cloudflared tunnel route dns max-mini <your-awesome-domain>

Then a config file at ~/.cloudflared/config.yml pointing the hostname at ssh://localhost:22, and the daemon installed as a launchd service so it survives reboots.

This is where I lost an hour to a launchd plist that was missing the actual command arguments. The sudo cloudflared service install command produced a plist that only ran /usr/local/bin/cloudflared with no flags. No --config, no tunnel run, nothing. So cloudflared would start, immediately exit cleanly because it had nothing to do, and launchd would dutifully not restart it because the exit code was 0. The service "ran" but did nothing. The tunnel showed zero active connections. The error message at the client end was a generic websocket handshake failure that gave me no hint about the actual cause.

The fix was to write the plist by hand with proper ProgramArguments:

<array>
    <string>/usr/local/bin/cloudflared</string>
    <string>--config</string>
    <string>/etc/cloudflared/config.yml</string>
    <string>tunnel</string>
    <string>run</string>
</array>

Plus moving the config and credentials JSON to /etc/cloudflared/ because the service runs as root and can't read a regular user's home directory. Reload, check cloudflared tunnel info max-mini for active connections, test from the laptop. Done.

The sudo wall

Setting up max as a non-admin user was the right call for security but it meant hitting the same wall repeatedly. Every time I needed sudo, I got the same message:

max is not in the sudoers file.
This incident has been reported to the administrator.

For a while I tried to work around this with su - ickas -c "sudo ..." patterns from inside max's shell. This works but the quote escaping gets ugly fast, especially with commands that contain nested strings.

The cleaner answer was two SSH sessions on my laptop. Tab one: ssh max@max-mini.local for daily work. Tab two: ssh ickas@max-mini.local for anything needing sudo. No su, no escape hell, just type the command in the right tab.

This required getting key-based authentication working for both accounts. I have one SSH key dedicated to this mini, copied to both max and ickas via ssh-copy-id, with ~/.ssh/config entries on the laptop pointing at the right key for each user:

Host max-mini
  HostName max-mini.local
  User max
  IdentityFile ~/.ssh/<ssh_key_id>
  IdentitiesOnly yes

Host max-mini-admin
  HostName max-mini.local
  User ickas
  IdentityFile ~/.ssh/<ssh_key_id>
  IdentitiesOnly yes

The IdentitiesOnly yes matters. Without it, ssh tries every key it has, runs out of attempts before reaching the right one, and falls back to password. With it, ssh only offers the specified key and the connection is fast and clean.

For remote access through Cloudflare, two parallel entries:

Host max-mini-remote
  HostName <your-awesome-domain>
  User max
  ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
  IdentityFile ~/.ssh/<ssh_key_id>
  IdentitiesOnly yes

And the equivalent for ickas. Now I have four aliases on the laptop, two for home, two for outside, two for daily and two for admin. The vocabulary is small and predictable.

Locking down SSH

Once key auth was working for both users on both connection paths, I disabled SSH password authentication entirely.

sudo tee /etc/ssh/sshd_config.d/100-hardening.conf > /dev/null <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
EOF
sudo launchctl kickstart -k system/com.openssh.sshd

The pattern that matters here is the safety net: keep your existing SSH session open. Open a second new session from the laptop to verify the new config works. If it doesn't, the first session is still alive and you can roll back by deleting the hardening file. Don't ever apply SSH config changes and then close your only session. That's how people lock themselves out and end up driving home to plug in a monitor.

With password auth disabled, the only way into the mini is via a private key. Even if someone discovered the hostname (it's a public CNAME, so they can), and even if they could reach the SSH server through Cloudflare, they'd hit a brick wall at authentication. No brute-force surface left.

Node, Claude Code, and the rest

The agent toolchain itself is the easiest part. Homebrew for system tools (git, gh, tmux, a few ripgrep-style replacements I like). nvm for Node, installed via the official curl script rather than Homebrew because brew-installed nvm has known quirks. Then Node LTS, npm, and Claude Code.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
nvm install --lts
npm install -g @anthropic-ai/claude-code

First-time auth for Claude Code over SSH is interesting. It prints a URL that I open on my laptop browser, authenticate there, and the token comes back to the CLI on the mini. No display needed on the mini side.

The result is a Mac mini I can reach from anywhere, that runs Claude Code as a non-admin user, with its disk encrypted, its SSH locked down to keys, and its tunnel surviving reboots.

What I'd do differently

A few things I'd save myself if I were doing this again.

The Cloudflare service-install bug cost me real time. If I were setting this up fresh, I'd write the launchd plist by hand from the start instead of trusting sudo cloudflared service install. The official installer produces a non-functional plist, at least on Intel macOS. Knowing that, the right approach is "expect to write the plist yourself."

The sudo workflow took longer to settle than it should have. I tried two or three half-measures before just opening two SSH tabs on the laptop. Two tabs is the answer, accept it early, move on.

I'd also commit to either SSH-key auth from minute one or accept password auth permanently. Going halfway, with max on keys and ickas on password, meant I couldn't lock down SSH until I went back and copied keys to ickas. Doing both at the same time would have been faster.

Next: Hermes

The mini is now what I wanted it to be. Reachable from anywhere, locked down, ready to run things. The next layer is Hermes, the self-improving open-source agent from Nous Research. The selling point is a long-lived service that accumulates context over time, paired with local or cloud models. On an i3 with limited RAM I won't be running anything sizeable locally, so it'll have to use cloud inference, but that's fine for what I want.

There's also the question of what specifically I want these agents doing day-to-day. A Telegram bot that handles a few personal things. A scheduled job that summarizes my inbox in the morning. A long-running Claude Code session that does background work on side projects. The infrastructure is the boring part. Now I get to play with what runs on top of it.

That's the next post.