시작
지난 편 (#6) 에서 Vultr Seoul VPS 에 paper 자동매매를 띄웠다. cron 이 돌고 매매가 일어나는 건 확인했지만, 실제로 그 결과를 보러 가는 길은 여전히 번거로웠다. 매번 ssh -L 8080:localhost:8080 으로 터널 띄우고, 코드 한 줄 바꿀 때마다 다시 ssh 들어가서 git pull && docker compose up -d --build 손으로. 운영 페이스가 안 잡혔다. 이번엔 그걸 정리한 작업.
무엇을 했나
- 라이브 누적 실현손익 카드 + 청산 거래 상세 모달 (PaperAccountCard 안에 통합)
- VPS SSH 키 인증 전환 + fail2ban (셋업 6분만에 봇 1건 ban 됨)
- Tailscale 사설망 — Mac → VPS 자동 라우팅,
ssh -L불필요 - ufw + Tailscale-only 바인딩 — 외부 노출 SSH 22 만
- GitHub Actions self-hosted runner —
git push하면 1분 안에 자동 배포
시행착오 3개
(a) Mac DB ≠ VPS DB — 30분을 헤매다
실현손익 카드를 만들고 web 에 띄웠는데 데이터가 안 보였다. 디버깅하다 보니 web 이 호출하는 API 가 Mac 의 docker 8080 이었고, 그 API 는 4일 전 Mac DB 를 보고 있었다. 매매는 VPS 에서 일어났는데 두 DB 가 별개라는 사실을 잠깐 잊고 있었다.
[Mac 브라우저 :3000] → localhost:8080 → Mac docker api → Mac DB ← 4일 전 dump 시점
[VPS cron 15:20] → run-live.sh → VPS DB ← 매매 결과
docker compose stop api engine 으로 Mac docker 를 끄고 ssh -L 로 VPS API 를 보게 전환하니 비로소 카드에 +₩6,271,600 이 표시됐다. 이 잠깐의 헤맴이 다음 두 시행착오 (Tailscale 도입, docker-compose.override.yml 분리) 의 동기가 됐다.
(b) cloud-init 이 sshd_config 를 조용히 override
SSH 키 인증으로 전환하면서 /etc/ssh/sshd_config 의 PasswordAuthentication no 로 변경했는데, 비번 인증이 계속 통과됐다. effective 값을 직접 보면:
sshd -T -C user=root -C host=localhost -C addr=127.0.0.1 -f /etc/ssh/sshd_config | grep password
# → passwordauthentication yes ← ?
cloud image 의 흔한 함정이었다. /etc/ssh/sshd_config.d/50-cloud-init.conf 가 메인 config 를 덮어쓰고 있었다. 그 파일에서 yes → no 로 바꾸고 ssh 재시작하자 효과가 적용됐다.
교훈 —
grep -E "PasswordAuthentication" /etc/ssh/sshd_config만 보고 안심하지 말고sshd -T로 effective 값 을 확인할 것. cloud image 는 거의 다sshd_config.d/include 가 걸려있다.
(c) docker 의 iptables 가 ufw 를 우회
ufw 로 22 외 모두 차단 + Tailscale interface 만 허용한 뒤 외부 차단 검증:
curl --max-time 5 http://<VPS-PUBLIC-IP>:8080/api/...
# → 정상 응답 ← 막혔어야 하는데?
docker 가 자체 iptables DOCKER chain 으로 패킷을 직접 라우팅한다. ufw 의 INPUT chain 을 안 거치는 게 docker 의 설계 의도라서 ufw 만으로는 못 막는다. 우회법 두 가지:
daemon.json에iptables: false— docker 가 iptables 못 만지게. 다른 부작용 위험ports바인딩을 외부 NIC 에 안 하기 — listen 자체를 막음
(2) 가 정공법. 0.0.0.0:8080:8080 → <TAILSCALE-IP>:8080:8080 (Tailscale IP) 로 변경하면 외부 NIC 에선 listen 안 함. 단, 이걸 git 추적 docker-compose.yml 에 박으면 Mac 개발 환경에선 못 쓴다. 그래서 docker-compose.override.yml 패턴:
# docker-compose.override.yml — VPS 에만, gitignore 됨
services:
api:
ports: !override
- "<TAILSCALE-IP>:8080:8080"
engine:
ports: !override
- "<TAILSCALE-IP>:8000:8000"
!override 는 docker compose v2.24+ 의 list-merge 우회 태그. 본 yml 의 ports list 와 append 되는 게 아니라 완전 교체. 이러면 본 yml 은 Mac 기준 127.0.0.1 그대로 유지하고, VPS 에선 override 가 자동 merge 되어 Tailscale IP 바인딩.
운영 흐름 (after)
[Mac] git push origin dev
↓ (1분 안)
[GitHub Actions → VPS runner (outbound polling)]
↓
deploy.sh — git pull + docker compose up -d --build api engine
↓
[Mac 브라우저 localhost:3000] Tailscale 통해 즉시 반영
self-hosted runner 의 핵심 — inbound 포트 0개. VPS 가 GitHub 으로 outbound polling 하는 패턴이라 외부에 새 endpoint 를 노출하지 않는다. 직전 단계에서 닦은 ufw + 키 인증 + fail2ban 보안을 그대로 유지한 채 자동 배포가 얹혔다.
운영 흐름 비교:
| 작업 | Before | After |
|---|---|---|
| 새 코드 배포 | ssh + git pull + rebuild 4단계 | git push 만 |
| UI 보러 가기 | ssh -L 8080:... 매번 | Tailscale 켜놓기만 |
| 외부 봇 차단 | docker 가 ufw 우회해서 사실상 노출 | Tailscale IP 만 listen, 외부 timeout |
회고
VPS 셋업 자체보다, 그 다음날의 잡일을 제거하는 데 비슷한 시간이 들었다. 운영 가능한 시스템과 실험용 시스템의 차이는 거의 다 이런 잡일에 있다.
오늘 배운 거 세 가지:
- cloud image 의
sshd_config.d/override — 메인 config 만 보면 안전하다고 착각.sshd -T로 effective 값 검증이 정공법 - docker iptables chain 이 ufw 우회 —
ports바인딩을 외부 NIC 에 하지 않는 게 가장 단순한 해결 - self-hosted runner 의 outbound polling 패턴 — 보안 hardening 과 자동 배포가 충돌하지 않는 이유
paper 1-2개월 검증 동안은 이 흐름으로 충분할 듯하다. 실전 모드 가기 전엔 (1) runner 를 비-root user 로 격리, (2) VPS 정기 스냅샷 정도만 추가 예정.