집에 있는 맥미니 한 대를 "내 개인 AI 서버"로 만들어서, 노트북·아이패드·폰 어디서든 붙어 쓰는 이야기입니다. 터미널 창을 닫아도, 로그아웃해도, 재부팅해도 안 죽습니다.
소개
시도하고자 했던 것과 그 이유
저는 Hermes Agent(오픈소스 AI 에이전트)를 텔레그램·슬랙 봇으로 붙여서 씁니다. 메신저로 "이거 조사해줘", "이 파일 정리해줘" 하면 알아서 해주는 개인 비서예요.
이번에 헤르메스 에이전트 셋팅을 하면서 집에 있는 맥미니를 서버로 하여 24시간 내 비서가 가동하면서 메신저 앱, 데스크탑앱을 다른 디바이스에서도 끊김없이 작업하기 위한 환경을 셋팅하기 위해 아래와 같은 목표를 세워 진행했습니다.
목표
뜻
24/7 상주
터미널 닫아도, 로그아웃해도, 재부팅해도 알아서 살아있기
내 기기에서만 접속
노트북·아이패드·폰에서는 되고, 인터넷 아무나는 안 되기
데스크탑 앱으로
맥북에서는 브라우저 말고 정식 Hermes 앱으로 서버에 붙기
이 세 개를 launchd + Tailscale + Hermes Desktop remote 조합으로 풀었습니다.
진행 방법
전체 그림 먼저
┌─────────────────────── 맥미니 (24시간 켜둠) ───────────────────────┐
│ │
│ launchd (macOS 자동실행 관리자) │
│ ├── ai.hermes.gateway ← 메신저 봇 (죽으면 자동 재시작) │
│ └── ai.hermes.dashboard ← 웹 관리화면 (죽으면 자동 재시작) │
│ │ │
│ └─ 100.x.y.z:9119 에만 바인딩 │
│ (= Tailscale 주소에만 열림) │
└────────────────────────────────────┼───────────────────────────────┘
│ Tailscale 사설망 (암호화)
┌────────────────────────────┼────────────────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ 맥북 │ │ 아이패드 │ │ 폰 │
│ Hermes │ │ 브라우저 │ │ 브라우저 │
│ Desktop │ └─────────┘ └─────────┘
│ (remote)│
└─────────┘
❌ 인터넷의 다른 사람들 — 아예 도달 불가핵심 아이디어는 딱 두 줄입니다.
launchd로 "죽으면 다시 켜주는 관리인"을 붙인다.
Tailscale IP에만 포트를 연다 → 내 기기들만 보이고, 인터넷에는 아예 안 열린다.
준비물
항상 켜둘 맥 한 대 (맥미니가 전기도 적게 먹고 조용해서 딱입니다)
Hermes Agent 설치 완료 (
~/.hermes/hermes-agent/)Tailscale 계정 (무료 플랜으로 개인 기기 100대까지 충분)
💡 맥미니를 서버로 쓸 거면 시스템 설정 → 에너지 절약에서 "가능하면 하드 디스크를 잠자기 상태로 두기" 해제, "정전 후 자동으로 다시 시작" 켜기, "디스플레이 꺼도 잠자기 안 함"을 먼저 잡아두세요. 이거 안 하면 launchd가 아무리 좋아도 맥이 자버립니다.
Step 1. Tailscale 깔고 내 서버 주소 확인하기
Tailscale은 내 기기들끼리만 통하는 가상의 사내망을 만들어줍니다. 공유기 포트포워딩도, 고정 IP도, DDNS도 필요 없어요. 설치하고 로그인만 하면 각 기기가 100.x.y.z 형태의 주소를 하나씩 받습니다.
맥미니, 맥북, 폰, 아이패드에 각각 깔고 같은 계정으로 로그인하세요.
맥미니에서 내 주소를 확인합니다.
tailscale ip -4
# 출력 예: 100.101.102.103# 내 기기 목록 확인 — 여기 뜨는 기기들만 서버에 접근할 수 있게 됩니다
tailscale status이 IP는 노드마다 고정이라 웬만해선 안 바뀝니다. 아래 설정에 계속 쓰이니 메모해두세요. (이 글에서는 100.101.102.103을 예시로 씁니다. 본인 IP로 바꿔서 따라 하세요.)
컴퓨터의 장치 설정 스크린샷
Step 2. 대시보드를 "Tailscale 주소에만" 열기
여기가 이 셋팅의 심장입니다.
hermes dashboard --host 100.101.102.103 --port 9119--host에 Tailscale IP를 박는 것이 포인트예요. 이러면 9119 포트는 Tailscale 인터페이스에만 열립 니다.
✅ 내 Tailscale 기기들 → 접속됨
❌ 같은 집 와이파이의 다른 기기 → 안 됨
❌ 인터넷 → 안 됨
⚠️ 절대 하면 안 되는 것:
--host 0.0.0.0. 이건 "모든 인터페이스에 열어라"는 뜻이라 공유기 설정에 따라 인터넷에 노출될 수 있습니다. API 키가 들어있는 화면이니 절대 금지.
인증도 반드시 켜세요. 루프백(127.0.0.1)이 아닌 주소에 바인딩할 때 Hermes는 인증을 요구합니다. ~/.hermes/.env에 아래 세 줄을 넣습니다.
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=원하는아이디
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=충분히_긴_랜 덤_비밀번호
HERMES_DASHBOARD_BASIC_AUTH_SECRET=세션서명용_랜덤_문자열비밀번호와 시크릿은 이렇게 뽑으면 편합니다.
openssl rand -base64 24 # 두 번 실행해서 각각 PASSWORD / SECRET 에 사용💬 Hermes에는 인증을 끄는
--insecure옵션도 있습니다. 저는 처음에 "어차피 내 기기뿐인데" 하고 그걸 썼는데, 기기를 하나 추가하는 순간 그 기기도 API 키 화면에 그냥 들어갈 수 있다는 걸 깨닫고 BASIC_AUTH를 켜는 쪽으로 바꿨습니다. 자물쇠는 두 개 채우는 게 맞아요.
Step 3. launchd로 24시간 상주시키기
리눅스 글들은 대부분 systemd로 설명하는데, 맥에는 systemd가 없습니다. 맥의 짝은 launchd예요. 사용자 서비스는 ~/Library/LaunchAgents/에 plist(XML 설정파일) 하나 넣어주면 끝입니다.
아래 파일을 ~/Library/LaunchAgents/ai.hermes.dashboard.plist로 저장하세요. YOUR_NAME(맥 계정명)과 100.101.102.103(Tailscale IP), 그리고 PATH의 노드 버전은 본인 환경에 맞게 바꿔야 합니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.hermes.dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_NAME/.hermes/hermes-agent/venv/bin/python</string>
<string>-m</string>
<string>hermes_cli.main</string>
<string>dashboard</string>
<string>--host</string>
<string>100.101.102.103</string>
<string>--port</string>
<string>9119</string>
<string>--no-open</string>
<string>--skip-build</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOUR_NAME/.hermes/hermes-agent</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/YOUR_NAME/.hermes/hermes-agent/venv/bin:/Users/YOUR_NAME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>VIRTUAL_ENV</key>
<string>/Users/YOUR_NAME/.hermes/hermes-agent/venv</string>
<key>HERMES_HOME</key>
<string>/Users/YOUR_NAME/.hermes</string>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/Users/YOUR_NAME/.hermes/logs/dashboard.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_NAME/.hermes/logs/dashboard.error.log</string>
</dict>
</plist>각 옵션이 하는 일 (이게 이해되면 다른 서비스도 다 만들 수 있습니다)
키
하는 일
Label
서비스 이름. launchctl 명령에서 이 이름으로 부릅니다
ProgramArguments
실행할 명령. 터미널에서 치던 걸 띄어쓰기 단위로 쪼개서 넣습니다
EnvironmentVariables → PATH
launchd는 내 셸 설정(.zshrc)을 안 읽습니다. PATH를 직접 안 주면 "command not found"가 납니다. ⚠️ 초보자 1번 함정
RunAtLoad
로그인/부팅 시 자동 실행
KeepAlive → SuccessfulExit: false
비정상 종료 시 자동 재시작. 이게 "안 끊긴다"의 정체입니다
StandardOutPath / ErrorPath
로그 파일 경로. 문제 생기면 여기부터 봅니다
--skip-build
미리 빌드해둔 화면을 그대로 서빙. 재부팅할 때마다 npm 빌드를 돌리지 않아서 훨씬 튼튼합니다
등록하고 켜기
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/ai.hermes.dashboard.plist
launchctl enable "gui/$(id -u)/ai.hermes.dashboard"
launchctl kickstart -k "gui/$(id -u)/ai.hermes.dashboard"잘 떴는지 확인
# state = running 이면 성공
launchctl print "gui/$(id -u)/ai.hermes.dashboard" | grep -E 'state|pid'
# HTTP 응답 확인 (인증을 켜면 302, 껐으면 200)
curl -s -o /dev/null -w '%{http_code}\n' http://100.101.102.103:9119/설정을 고쳤을 때 / 끌 때
# plist 수정 후 반영
launchctl bootout "gui/$(id -u)/ai.hermes.dashboard"
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/ai.hermes.dashboard.plist
# 로그 실시간으로 보기 (문제 생기면 1순위)
tail -f ~/.hermes/logs/dashboard.error.log메신저 봇(게이트웨이)도 똑같이 하면 됩니다. 위 plist를 복사해서 Label을 ai.hermes.gateway로, ProgramArguments의 dashboard 자리를 게이트웨이 실행 인자로 바꾸면 끝이에요. 저는 이 패턴으로 용도가 다른 봇 7개를 각각 별도 서비스로 띄워서 쓰고 있습니다. (그 얘기는 길어서 다음 글에…)
Step 4. 배포 스크립트로 묶기 (재현 가능하게)
위 과정을 매번 손으로 하면 언젠가 한 단계를 빼먹습니다. 그래서 몇 번을 실행해도 결과가 같은(멱등) 스크립트로 묶어뒀어요. 저는 이걸 infra/launchd/install-dashboard.sh로 깃에 넣어 관리합니다.
#!/usr/bin/env bash
# Hermes 웹 대시보드 24/7 launchd 서비스 배포 (Tailscale IP 바인딩). 멱등 — 재실행 안전.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLIST_SRC="$REPO_DIR/ai.hermes.dashboard.plist"
LABEL="ai.hermes.dashboard"
PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
HERMES_AGENT="$HOME/.hermes/hermes-agent"
WEB_DIR="$HERMES_AGENT/web"
DIST="$HERMES_AGENT/hermes_cli/web_dist" # vite outDir — web/dist 아님!
BIND_IP="100.101.102.103" # ← 본인 Tailscale IP
PORT="9119"
echo "==> Pre-flight"
[ -x "$HERMES_AGENT/venv/bin/python" ] || { echo "✗ hermes venv python 없음"; exit 1; }
[ -f "$PLIST_SRC" ] || { echo "✗ plist 원본 없음: $PLIST_SRC"; exit 1; }
# 바인딩 IP가 이 호스트의 실제 Tailscale IP와 같은지 점검 (다르면 경고)
TS_IP="$(/Applications/Tailscale.app/Contents/MacOS/Tailscale ip -4 2>/dev/null || tailscale ip -4 2>/dev/null || true)"
if [ -n "$TS_IP" ] && [ "$TS_IP" != "$BIND_IP" ]; then
echo "⚠ Tailscale IP($TS_IP) != plist 바인딩 IP($BIND_IP). plist를 갱신 후 진행하세요." >&2
fi
echo "==> web dist 확인 ($DIST)"
if [ ! -f "$DIST/index.html" ]; then
echo " dist 없음 — 빌드 (npm install && npm run build)…"
( cd "$WEB_DIR" && npm install && npm run build )
else
echo " dist 존재 — 빌드 생략 (강제 재빌드: '$DIST' 삭제 후 재실행)"
fi
[ -f "$DIST/index.html" ] || { echo "✗ dist 빌드 실패"; exit 1; }
echo "==> 잔여 대시보드 프로세스 정리 (메신저 봇은 건드리지 않음)"
hermes dashboard --stop || true
echo "==> plist 설치 -> $PLIST_DST"
mkdir -p "$HOME/Library/LaunchAgents"
cp "$PLIST_SRC" "$PLIST_DST"
echo "==> launchd 서비스 (재)로드"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST_DST" 2>/dev/null || launchctl load -w "$PLIST_DST"
launchctl enable "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$LABEL" 2>/dev/null || true
echo "==> 검증 (바인딩 대기…)"
sleep 4
launchctl print "gui/$(id -u)/$LABEL" 2>/dev/null | grep -E "state =|pid =" | head || true
code="$(curl -s -o /dev/null -w '%{http_code}' "http://$BIND_IP:$PORT/" || true)"
echo " http://$BIND_IP:$PORT/ -> HTTP $code"
echo "✓ 완료. Tailscale 기기 브라우저에서 http://$BIND_IP:$PORT 접속 (이름 말고 IP로!)"chmod +x infra/launchd/install-dashboard.sh
./infra/launchd/install-dashboard.shStep 5. 폰·아이패드·노트북에서 접속하기
이제 Tailscale이 켜진 기기의 브라우저에서 아래 주소를 열면 됩니다.
http://100.101.102.103:9119⚠️ 여기서 제가 30분 날렸습니다. Tailscale은
내기기이름.tailXXXX.ts.net같은 예쁜 이름(MagicDNS)도 주는데, 그 이름으로 접속하면400 Invalid Host header가 뜹니다.버그가 아니라 보안 기능이에요. 대시보드가 들어온 Host 헤더를 바인딩 주소와 엄격하게 대조합니다(DNS 리바인딩 공격 방어). IP로 바인딩했으면 IP로만 통과해요. 그냥 IP를 북마크에 넣으세요.
반대로
--host에 MagicDNS 이름을 넣으면 이름으로 접속되고 IP가 막힙니다. IP가 바뀔 걱정이 없으니 저는 IP 쪽을 택했습니다.
Step 6. 맥북에서 Hermes Desktop을 "리모트"로 붙이기
브라우저로도 충분하지만, 맥북에서는 정식 데스크탑 앱으로 쓰는 게 훨씬 낫습니다. 핵심은 맥북에서 AI를 돌리는 게 아니라, 맥미니 서버에 붙기만 한다는 점이에요. 맥북은 화면 역할만 합니다. 그래서 맥북 배터리도 안 닳고, 맥북을 덮어도 서버는 계속 일합니다.
맥북에 아래 파일을 만듭니다.
~/Library/Application Support/Hermes/connection.json
{
"mode": "remote",
"remote": {
"url": "http://100.101.102.103:9119",
"authMode": "oauth"
}
}이게 전부입니다. 앱을 실행하면 알아서 서버에 붙어요.
연결됐는지는 로그로 확인합니다.
tail -20 ~/.hermes/logs/desktop.log
# "Remote Hermes backend is ready" 가 보이면 성공Adobe Adobe Adobe Adobe adobe a 설정의 스크린샷
데스크탑 앱 빌드에서 막힌다면 — 저는 hermes desktop 빌드가 electronDist does not exist로 실패해서 한참 헤맸습니다. 네트워크 문제가 아니라 npm workspaces가 Electron을 하위 폴더에 두는데 빌더는 루트에서 찾는 경로 불일치였어요. 심링크 한 줄이면 해결됩니다.
ln -sfn ~/.hermes/hermes-agent/apps/desktop/node_modules/electron \
~/.hermes/hermes-agent/node_modules/electron
cd ~/.hermes/hermes-agent/apps/desktop && npm run pack빌드한 앱을 /Applications로 옮길 때는 cp -R을 쓰면 안 됩니다. 코드 서명이 깨져서 "앱이 손상되었습니다"가 떠요. ditto로 복사하고 재서명하세요.
ditto ~/.hermes/hermes-agent/apps/desktop/release/mac-arm64/Hermes.app /Applications/Hermes.app
xattr -cr /Applications/Hermes.app
codesign --force --deep --sign - /Applications/Hermes.app
codesign --verify /Applications/Hermes.app # "valid on disk" 확인빌드 없이 바로 써보고 싶다면 hermes desktop --source 한 줄이면 됩니다. (매번 40초쯤 빌드하지만 셋팅은 0)
Step 7. 최종 점검 체크리스트
여기까지 왔으면 아래 5개가 전부 통과해야 합니다.
# 1) 서비스가 running 인가
launchctl print "gui/$(id -u)/ai.hermes.dashboard" | grep -E 'state|pid'
# 2) 대시보드가 응답하는가 (인증 켰으면 302, 껐으면 200)
curl -s -o /dev/null -w '%{http_code}\n' http://100.101.102.103:9119/
# 3) 엉뚱한 이름으로는 막히는가 (400이 나와야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: bogus.example.com' http://100.101.102.103:9119/
# 4) 메신저 봇도 살아있는가
hermes gateway status
# 5) 진짜 안 죽는지 — 강제로 죽여보고 되살아나는지 확인 (KeepAlive 테스트)
pkill -f 'hermes_cli.main dashboard' ; sleep 15
launchctl print "gui/$(id -u)/ai.hermes.dashboard" | grep -E 'state|pid' # 새 pid로 running5번이 이 글의 하이라이트입니다. 프로세스를 강제로 죽였는데 15초 뒤에 새 PID로 살아나 있으면, 그게 바로 "24시간 안 끊긴다"의 실물 증거예요.
결과와 배운 점
결과
맥미니가 재부팅되든, 로그아웃하든, 프로세스가 죽든 알아서 되살아나는 상태가 됐습니다. 한 달 넘게 운영 중인데 제가 손댄 적은 없어요.
폰·아이패드·노트북 어디서든 Tailscale만 켜져 있으면 내 AI 서버에 붙습니다. 카페에서도, 지하철에서도 됩니다.
그런데 인터넷에는 포트가 하나도 안 열려 있습니다. 공유기 설정을 아예 안 건드렸어요.
맥북 데스크탑 앱은 서버에 붙기만 하니까, 맥북을 덮어도 서버 쪽 작업은 계속 돌아갑니다.
시행착오
① MagicDNS 이름으로 접속하면 400 Invalid Host header 버그인 줄 알고 한참 뒤졌는데 보안 기능이었습니다. 바인딩한 주소 형태(IP면 IP, 이름이면 이름)로만 접속됩니다. → IP를 북마크하세요.
② launchd가 "command not found"를 뱉음 launchd는 .zshrc를 안 읽습니다. 터미널에서 되던 게 launchd에서 안 되는 이유의 90%가 이겁니다. → plist의 EnvironmentVariables에 PATH를 직접 넣으세요.
③ 대시보드가 무한 새로고침(깜빡깜빡) 제가 쓰던 버전에 회귀 버그가 있었습니다. 인증 없는 모드에서 앱이 매번 인증 확인 API를 부르고 → 설계상 항상 401 → 그걸 "토큰 만료"로 오해해서 페이지를 새로고침 → 무한루프. 상위 저장소에 이미 수정 커밋이 있어서 그걸 가져다 붙였는데, 제 버전 컷팅 바로 다음 날 머지된 거라 한 끗 차로 못 받은 거였어요. → 증상이 이상하면 내 설정을 의심하기 전에 업스트림 커밋 로그부터 보세요. 그리고 그런 로컬 수정은 업데이트 때 덮어써지니 어딘가에 반드시 적어두세요.
궁금한 점 댓글 주시면 아는 만큼 답해드릴게요. 특히 "우리 집 맥미니로도 될까요?"는 됩니다. 항상 켜둘 수만 있으면 돼요.