Reaching a home Mac mini from anywhere, with a reverse SSH tunnel and two AI agents
Reaching my home Mac mini from anywhere, with a reverse SSH tunnel and two AI agents
I left the house with my laptop and wanted to get to the Mac mini sitting at home. Three things were in the way:
- The mini sits behind a home router with no port forwarding, and I didn't want to add any.
- My home IP address isn't fixed.
- I was on someone else's network, which I don't control.
What I did have was a small cloud VPS, plus a Claude Code session already running on the mini that I could message from my laptop. This post covers how I set up a reverse SSH tunnel through the VPS, and the security choices behind it.
Names, addresses, ports and usernames in this post are placeholders. Swap in your own.
The idea: let the mini dial out
My first instinct was the classic setup: open an SSH port on the home router and tell the server where home is. That approach has two problems. Port-forwarding a home machine to the whole internet isn't great, and a changing home IP means you also need dynamic DNS.
A reverse tunnel flips the direction. Home routers let outbound connections through without any configuration, so the mini connects out to the VPS and asks it to listen on a port. Anything sent to that port on the VPS travels back down the mini's connection to the mini's own SSH server.
laptop ──ssh──▶ relay (VPS) ──▶ relay's localhost:<TUNNEL_PORT> ──(mini's outbound connection)──▶ mini:22
▲
the mini keeps this open: ssh -N -R localhost:<TUNNEL_PORT>:localhost:22 relay
This means:
- The home router needs no port forwarding.
- The relay never needs to know my home IP.
- The tunnel port listens only on the relay's localhost, so it isn't exposed to the internet. To use it you have to get into the relay first.
Step 0: when "it's blocked" was really "wrong port"
My laptop couldn't reach the VPS at all: 22, 80 and 443 all timed out, so I assumed a firewall was blocking me. The session on the mini checked from its side and found the actual cause. The relay's SSH server listens on a non-standard port, and nothing at all listens on 22, 80 or 443. The cloud firewall was fine. I had been knocking on the wrong door.
The lesson: before you go after the firewall, check which ports are actually listening (ss -tln on the server).
Step 1: keys, not passwords
On the relay:
- Password login is off, and so is root login.
- I added my laptop's public key to the relay account's
authorized_keys.
On the mini:
- I added the same laptop key to the mini's
authorized_keys, so the last hop also uses keys.
Step 2: a dedicated tunnel key that can't do anything else
The mini has to log in to the relay without anyone typing a passphrase, so its tunnel key has no passphrase. To limit the damage if that key ever leaks, I gave it its own keypair and restricted what it can do in authorized_keys on the relay:
restrict,port-forwarding,permitlisten="localhost:<TUNNEL_PORT>",command="/bin/false" ssh-ed25519 AAAA... mini-tunnel
restrictturns off everything (no shell window, no agent forwarding, and so on)...- ...then
port-forwardingturns forwarding back on, permitlistenlets it open only that one port, and only on the relay's localhost,command="/bin/false"means that even if someone runs a command with this key, they get nothing.
I tested it: running ssh -i tunnel_key relay echo test returns exit code 1 with no output, and the forward still works.
Step 3: keep it up
The mini runs the tunnel under launchd, macOS's built-in service manager. You don't need autossh, because plain ssh with keepalives plus KeepAlive in launchd does the same job:
/usr/bin/ssh -N -i ~/.ssh/tunnel_key \
-o IdentitiesOnly=yes -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o BatchMode=yes \
-p <RELAY_SSH_PORT> -R localhost:<TUNNEL_PORT>:localhost:22 [email protected]
ExitOnForwardFailure: if the port can't be claimed, ssh exits instead of sitting there doing nothing, and launchd restarts it.ServerAliveInterval/CountMax: catches a dead connection in about 90 seconds.- The launchd job has
KeepAlive,RunAtLoad, and aThrottleIntervalof 30 seconds so it can't restart in a tight loop.
Login-time vs boot-time. A per-user LaunchAgent (~/Library/LaunchAgents) only runs once you've logged in. If the mini reboots after a power cut, the tunnel stays down until someone logs in. A LaunchDaemon (/Library/LaunchDaemons) runs at boot. I moved the tunnel to a LaunchDaemon with a UserName key, so it still runs as my normal user rather than root. Installing it needs sudo once. The switch-over drops any session that's using the tunnel, so the install script hands over in the background and you reconnect after about a minute.
Step 4: one command from the laptop
In ~/.ssh/config on the laptop:
Host relay
HostName relay.example.com
User <relay-user>
Port <RELAY_SSH_PORT>
Host mini
HostName localhost
Port <TUNNEL_PORT>
User <mini-user>
ProxyJump relay
HostKeyAlias my-mac-mini
ProxyJump handles the hop through the relay. HostKeyAlias stores the mini's host key under its own name. Without it, ssh would file the mini's key under "localhost", where it could clash with other machines.
Now ssh mini works from any network.
Note: macOS Remote Login only looked like it was off
launchctl print system/com.openssh.sshd said state = not running, so we assumed Remote Login was off and that I would need sudo to turn it on. But nc -z localhost 22 connected. macOS starts sshd only when a connection arrives, so "not running" just means no connection is open at the moment. Test the port, not the process list.
How the AI agents worked together
I did all of this from my laptop while away from home. The laptop's Claude Code session couldn't reach the mini directly, but my other Claude Code sessions show up over Remote Control, including one on the mini. So the laptop session sent the mini session messages: first "what machine are you on?", then "check the deploy", then "build the tunnel".
The part I liked: the mini's session refused to make the security changes on approval that came through the other session. New key trust, a background service and an SSH server are standing changes across two machines, and it wanted to hear from me directly. I approved it in that session from my phone, and then it went ahead. The laptop session also had its first attempt to delegate the setup stopped by its own safety check until I explicitly approved it. That's two safety stops for one tunnel. A bit of friction, but it's the friction you want for anything that opens a permanent way into your home machine.
Checklist
- Reverse tunnel bound to the relay's localhost only
- Key-only SSH on every hop, no root login on the relay
- Dedicated tunnel key, restricted to one forward with no shell
- Auto-reconnect through launchd, with no extra software
- Boot-time LaunchDaemon running as a normal user
- Possible next steps: alert when the tunnel drops, and rotate the tunnel key on a schedule