Tailscale VPN 적용 후 GitHub Actions SSH 배포 복구하기
Oracle Cloud 서버에 Tailscale SSH를 적용했더니 CI/CD 배포가 깨졌습니다. appleboy/ssh-action에서 tailscale ssh로 전환하여 SSH 키 없이 안전하게 배포하는 전체 과정을 공유합니다.
1. 문제 상황
Oracle Cloud Free Tier 서버에서 Next.js 앱을 Docker로 운영하고 있었습니다. 배포는 GitHub Actions에서 appleboy/ssh-action으로 서버에 SSH 접속하여 git pull → docker compose build → docker compose up 순서로 진행하고 있었습니다.
기존 배포 워크플로우는 이렇게 생겼습니다:
# .github/workflows/deploy.yml (기존)
deploy-production:
needs: ci
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to Production
uses: appleboy/[email protected]
with:
host: ${{ secrets.OCI_HOST }} # ← 공인 IP
username: ubuntu
key: ${{ secrets.OCI_SSH_KEY }}
script: |
cd ~/docker/my-app
git pull origin main
docker compose down
docker compose build --no-cache
docker compose up -d
여기까지는 잘 동작했습니다. 그런데 보안 강화를 위해 Tailscale VPN을 서버에 설치하고 SSH(TCP 22) 인바운드를 완전히 차단했습니다. 동시에 Tailscale SSH도 활성화하여 서버의 SSH 접속을 Tailscale identity 기반 인증으로 전환했습니다.
로컬에서는 Tailscale을 통해 SSH 접속이 잘 되지만, GitHub Actions runner는 Tailscale 네트워크에 속하지 않으므로 공인 IP로 접속을 시도하게 됩니다.
결과는 예상대로였습니다:
err: ssh: connect to host 129.xxx.xxx.xxx port 22: i/o timeout
정리하면:
- 로컬 → Tailscale IP → SSH 접속 성공
- GitHub Actions → 공인 IP → SSH timeout
2. 원인 분석
SSH 인바운드 차단의 의미
Tailscale을 도입하면서 서버의 UFW에서 SSH를 tailscale0 인터페이스로만 제한했습니다. 이는 공인 인터넷에서의 SSH 접속을 완전히 막는다는 의미입니다.
┌──────────────────┐ TCP 22 차단 ┌──────────────┐
│ GitHub Actions │ ──── 공인 IP:22 ──── X ────── │ OCI Server │
│ (runner) │ │ │
└──────────────────┘ └──────────────┘
┌──────────────────┐ Tailscale (WireGuard) ┌──────────────┐
│ 로컬 PC │ ──── 100.x.x.x:22 ── O ───── │ OCI Server │
│ (Tailscale 설치) │ │ (Tailscale) │
└──────────────────┘ └──────────────┘
Tailscale SSH가 만드는 추가 장벽
여기서 한 가지 더 고려할 점이 있습니다. 서버에 Tailscale SSH(RunSSH: true)가 활성화되어 있으면, Tailscale 네트워크를 통한 SSH 연결을 Tailscale이 가로채서 자체 identity 기반 인증으로 처리합니다.
즉, 일반 SSH 키로는 인증할 수 없습니다:
┌──────────────────┐ Tailscale 네트워크 ┌──────────────┐
│ GitHub Actions │ ──── SSH 키 인증 ── X ────── │ OCI Server │
│ (appleboy/ssh) │ handshake failed: EOF │ (Tailscale │
└──────────────────┘ │ SSH 활성) │
└──────────────┘
┌──────────────────┐ Tailscale 네트워크 ┌──────────────┐
│ GitHub Actions │ ── tailscale ssh ── O ────── │ OCI Server │
│ (tailscale ssh) │ Tailscale identity 인증 │ (Tailscale │
└──────────────────┘ │ SSH 활성) │
└──────────────┘
이것이 appleboy/ssh-action을 사용할 수 없는 근본적인 이유입니다. appleboy/ssh-action은 일반 SSH 키로 인증하는데, Tailscale SSH가 이를 거부합니다.
해결 방향
- Runner를 Tailscale 네트워크에 참여시킨다
appleboy/ssh-action대신tailscale ssh명령어를 직접 사용한다- Tailscale SSH ACL에서 CI runner의 접속을 허용한다
이렇게 하면 SSH 키 자체가 불필요해져서 오히려 보안이 향상됩니다.
3. 해결 방법
해결은 크게 네 단계로 진행됩니다:
- Tailscale ACL에 태그와 SSH 규칙 추가 — CI runner와 서버의 접근 제어
- 서버에 태그 부여 — SSH ACL의 대상 지정
- Tailscale OAuth Client 생성 — runner가 tailnet에 인증
- GitHub Actions 워크플로우 수정 —
tailscale ssh로 전면 교체
Step 1: Tailscale ACL 설정
Tailscale Admin Console → Access Controls에서 세 가지를 설정합니다.
1-1. tagOwners에 태그 정의
"tagOwners": {
"tag:ci": ["autogroup:admin"], // CI runner용
"tag:server": ["autogroup:admin"], // 서버용
},
1-2. SSH 규칙 추가
"ssh": [
// 기존: 본인 디바이스에 SSH (인증 체크 모드)
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"],
},
// 본인이 tag:server에 SSH (인증 체크 모드)
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["tag:server"],
"users": ["autogroup:nonroot", "root"],
},
// CI runner가 서버에 ubuntu로 SSH (자동 허용)
{
"action": "accept",
"src": ["tag:ci"],
"dst": ["tag:server"],
"users": ["ubuntu"],
},
],
주의사항:
tag:server를 부여한 디바이스는autogroup:self에서 벗어납니다. 따라서 두 번째 규칙을 추가하지 않으면 본인도 서버에 SSH 접속할 수 없게 됩니다.- CI runner는
action: "accept"(자동 허용)이고, 본인은action: "check"(브라우저 인증)입니다.
Step 2: 서버에 태그 부여
ACL 저장 후, 서버에서 태그를 부여합니다:
sudo tailscale up --advertise-tags=tag:server --ssh --accept-routes
tailscale set에는--advertise-tags옵션이 없으므로tailscale up을 사용해야 합니다. 기존 설정을 유지하려면 모든 non-default 플래그를 함께 지정해야 합니다.
Step 3: Tailscale OAuth Client 생성
Tailscale Admin Console → Settings → Trust credentials에서 OAuth Client를 만듭니다.
설정:
- Type: OAuth
- Description:
GitHubActionsCICD(영숫자만 허용) - Scope: Auth Keys → Write 체크 (Read 자동 포함)
- Tags:
tag:ci할당
생성하면 Client ID와 Client Secret이 발급됩니다.
Client ID: kXXXXXXXXXXXXXXXX
Client Secret: tskey-client-kXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX
주의: Client Secret은 생성 시 한 번만 표시됩니다. 반드시 복사해서 안전한 곳에 보관하세요.
Step 4: GitHub Secrets 설정 및 워크플로우 수정
GitHub Secrets
| Secret | 작업 | 값 |
|---|---|---|
TAILSCALE_CLIENT_ID |
신규 추가 | OAuth Client ID |
TAILSCALE_CLIENT_SECRET |
신규 추가 | OAuth Client Secret |
OCI_HOST |
삭제 가능 | tailscale ssh는 hostname으로 접속하므로 불필요 |
OCI_SSH_KEY |
삭제 가능 | Tailscale SSH는 키가 불필요 |
워크플로우 수정
appleboy/ssh-action을 완전히 제거하고 tailscale ssh로 교체합니다:
# .github/workflows/deploy.yml (최종)
deploy-production:
needs: ci
runs-on: ubuntu-latest
environment: production
steps:
- name: Connect to Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TAILSCALE_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_CLIENT_SECRET }}
tags: tag:ci
- name: Write env file on server
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
AUTH_URL: ${{ secrets.AUTH_URL }}
# ... 기타 환경변수
run: |
printf "DB_PASSWORD=%s\nAUTH_URL=%s\n" \
"$DB_PASSWORD" "$AUTH_URL" \
| tailscale ssh ubuntu@my-server "cat > ~/docker/my-app/.env"
- name: Deploy to Production
run: |
tailscale ssh ubuntu@my-server << 'DEPLOY_SCRIPT'
cd ~/docker/my-app
git pull origin main
docker compose down
docker compose build --no-cache
docker compose up -d
echo "Deployment completed"
DEPLOY_SCRIPT
핵심 변경점:
| Before | After |
|---|---|
appleboy/ssh-action |
tailscale ssh 명령어 직접 사용 |
host: ${{ secrets.OCI_HOST }} |
ubuntu@my-server (Tailscale hostname) |
key: ${{ secrets.OCI_SSH_KEY }} |
불필요 (Tailscale identity 인증) |
envs: 파라미터로 환경변수 전달 |
printf + stdin pipe로 env 파일 작성 |
oauth-client-secret: |
oauth-secret: (주의: 파라미터명이 다름) |
주의:
tailscale/github-action@v4의 올바른 파라미터명은oauth-secret입니다.oauth-client-secret이 아닙니다. 잘못 사용하면Please provide either an auth key, OAuth secret and tags, or federated identity client ID and audience with tags.에러가 발생합니다.
4. 트러블슈팅
실제 적용 과정에서 만난 문제들과 해결 방법입니다.
4-1. ssh: handshake failed: EOF
증상: Tailscale 연결은 성공하지만 SSH에서 handshake failed: EOF 에러가 발생합니다.
원인: 서버에 Tailscale SSH(RunSSH: true)가 활성화되어 있으면, appleboy/ssh-action의 일반 SSH 키 인증이 거부됩니다. 서버의 auth.log에도 접속 시도 흔적이 남지 않는 것이 특징입니다.
확인 방법:
# 서버에서 Tailscale SSH 활성 여부 확인
sudo tailscale debug prefs 2>/dev/null | grep -i ssh
# "RunSSH": true ← 이 경우 appleboy/ssh-action 사용 불가
해결: appleboy/ssh-action을 버리고 tailscale ssh로 교체합니다 (Step 4 참조).
4-2. tag:ci is not allowed in src for autogroup:self
증상: ACL SSH 규칙에서 "dst": ["autogroup:self"]를 사용하면 에러가 발생합니다.
원인: autogroup:self는 태그된 노드에는 적용되지 않습니다. tag:ci로 태그된 CI runner가 autogroup:self 대상에 접근할 수 없습니다.
해결: 서버에 tag:server를 부여하고, "dst": ["tag:server"]로 지정합니다.
4-3. 서버 태그 부여 후 본인 SSH 차단
증상: 서버에 tag:server를 부여한 뒤 본인의 SSH 접속이 tailnet policy does not permit 에러로 차단됩니다.
원인: 태그가 부여된 디바이스는 autogroup:self에서 벗어납니다. 기존 SSH 규칙이 "dst": ["autogroup:self"]만 있으면 태그된 서버에 접근할 수 없게 됩니다.
해결: SSH 규칙에 "dst": ["tag:server"]를 추가합니다 (Step 1-2의 두 번째 규칙).
중요: 이 규칙을 서버에 태그를 부여하기 전에 ACL에 먼저 저장해야 합니다. 순서가 반대면 서버 접속이 차단됩니다.
4-4. oauth-client-secret 파라미터 에러
증상: Unexpected input(s) 'oauth-client-secret' 에러가 발생합니다.
원인: tailscale/github-action@v4의 올바른 파라미터명은 oauth-secret이지, oauth-client-secret이 아닙니다.
해결:
# X 틀림
oauth-client-secret: ${{ secrets.TAILSCALE_CLIENT_SECRET }}
# O 올바름
oauth-secret: ${{ secrets.TAILSCALE_CLIENT_SECRET }}
5. 핵심 개념 정리
최종 아키텍처
┌──────────────────┐ ┌──────────────┐
│ GitHub Actions │ 1. OAuth 인증 │ Tailscale │
│ Runner │ ──────────────────────────── │ API │
│ │ 2. Ephemeral 노드 참여 │ │
│ │ ◄──────────────────────────── │ │
│ (tag:ci) │ └──────────────┘
│ │
│ │ 3. tailscale ssh ┌──────────────┐
│ │ ──── Tailscale identity ───── │ OCI Server │
│ │ (SSH 키 불필요) │ (tag:server)│
└──────────────────┘ └──────────────┘
appleboy/ssh-action vs tailscale ssh
| appleboy/ssh-action | tailscale ssh | |
|---|---|---|
| 인증 방식 | SSH private key | Tailscale identity (OAuth) |
| 키 관리 | GitHub Secret에 보관 필수 | 불필요 |
| Tailscale SSH 호환 | X (handshake 실패) | O (네이티브 지원) |
| 환경변수 전달 | envs: 파라미터 |
printf + stdin pipe |
| 서버 지정 | IP 주소 | Tailscale hostname |
인증 방식 비교
Tailscale GitHub Action은 세 가지 인증 방식을 지원합니다:
| 방식 | 보안 | 설정 난이도 | 추천 |
|---|---|---|---|
| Workload Identity Federation | 높음 | 높음 | 엔터프라이즈 |
| OAuth Client | 중간 | 중간 | 대부분의 경우 추천 |
| Auth Key | 낮음 | 낮음 | 빠른 테스트 |
이 글에서는 OAuth Client 방식을 사용했습니다. Auth Key는 만료 관리가 필요하고, Workload Identity Federation은 설정이 복잡하기 때문입니다.
Ephemeral 노드의 장점
GitHub Actions runner는 매 job마다 새로운 VM이 생성되고 job 종료 시 파괴됩니다. tailscale/github-action이 생성하는 노드도 ephemeral로 설정되어 있어서:
- Job 완료 시 tailnet에서 자동 제거
- Machines 목록에 좀비 노드가 남지 않음
- 별도의 정리 작업 불필요
6. 베스트 프랙티스
체크리스트
- [ ] ACL에 태그 정의:
tag:ci(runner),tag:server(서버) - [ ] SSH ACL 규칙 추가:
tag:ci→tag:serveraccept + 본인 →tag:servercheck - [ ] ACL 저장 후 서버에
tag:server부여 (순서 중요!) - [ ] OAuth Client 생성: scope는
auth_keysWrite, 태그는tag:ci - [ ] GitHub Secrets 설정:
TAILSCALE_CLIENT_ID,TAILSCALE_CLIENT_SECRET - [ ] 불필요한 Secret 정리:
OCI_HOST,OCI_SSH_KEY삭제 가능 - [ ] 워크플로우:
appleboy/ssh-action→tailscale ssh교체 - [ ] 양쪽 브랜치 적용: production(main)과 staging(develop) 모두에 커밋
- [ ] 배포 확인: push 후 GitHub Actions 로그에서 Tailscale 연결 + SSH 배포 성공 확인
예방 방법: 인프라 변경 시 CI/CD 영향 분석
서버 네트워크 설정을 변경할 때는 반드시 CI/CD 파이프라인의 접속 경로를 함께 검토해야 합니다:
인프라 변경 체크리스트:
1. 방화벽 규칙 변경 → CI/CD runner의 접속 경로 확인
2. VPN 도입 → runner의 네트워크 참여 방법 확인
3. Tailscale SSH 활성화 → SSH 키 기반 도구 호환성 확인
4. 서버 태그 변경 → ACL SSH 규칙 영향 확인
Tailscale Free Tier 제한사항
| 항목 | Free | Personal Plus |
|---|---|---|
| 사용자 수 | 1 | 1 |
| 디바이스 수 | 3 | 10 |
| OAuth Client | O | O |
| ACL 커스터마이징 | O | O |
| Tailscale SSH | O | O |
Free Tier에서도 OAuth Client, ACL, Tailscale SSH를 모두 사용할 수 있습니다. 다만 디바이스 3대 제한에 주의하세요. Ephemeral 노드는 job 종료 시 자동 제거되므로 디바이스 제한에 걸리지 않습니다.
7. FAQ
Q: Auth Key 대신 OAuth Client를 사용하는 이유는 무엇인가요?
A: Auth Key는 만료일이 있어서 주기적으로 갱신해야 합니다 (최대 90일). OAuth Client는 만료되지 않으며, 런타임에 ephemeral auth key를 자동 생성합니다. 유지보수 부담이 훨씬 적습니다.
Q: Tailscale step이 배포 시간에 얼마나 영향을 주나요?
A: Tailscale 바이너리 다운로드 + tailnet 참여까지 보통 10-15초 정도 소요됩니다. 전체 배포 시간(Docker build 포함)에 비하면 미미한 수준입니다.
Q: appleboy/ssh-action을 계속 쓸 수 없나요?
A: Tailscale SSH가 비활성화된 서버라면 사용할 수 있습니다. 하지만 Tailscale SSH가 활성화되어 있으면 ssh: handshake failed: EOF로 실패합니다. Tailscale SSH를 사용하는 것이 SSH 키 관리가 불필요해져 보안상 더 유리합니다.
Q: 공인 IP로의 SSH 접속을 완전히 차단해도 되나요?
A: 네, Tailscale을 통해 접속하면 됩니다. 다만 Tailscale 자체에 접속할 수 없는 비상 상황(tailnet 장애 등)을 대비해 Oracle Cloud Console의 직렬 콘솔 접속 방법을 알아두는 것을 권장합니다.
Q: 여러 서버에 배포할 때는 어떻게 하나요?
A: TAILSCALE_CLIENT_ID/SECRET은 동일하게 사용하고, 각 서버에 tag:server를 부여하면 됩니다. Tailscale 연결 step은 job당 한 번만 실행하면 모든 tailnet 노드에 tailscale ssh로 접근할 수 있습니다.
Q: tags: tag:ci 설정은 왜 필요한가요?
A: ACL에서 이 태그를 기반으로 CI runner의 접근 범위를 제어할 수 있습니다. 또한 OAuth Client에 tag:ci를 할당했으므로, 이 태그를 사용해야 인증이 성공합니다. 태그 없이 생성하면 권한 오류가 발생합니다.
Q: 서버에 태그를 부여하면 왜 본인 SSH가 차단되나요?
A: Tailscale에서 태그가 부여된 디바이스는 개인 소유에서 벗어나 autogroup:self에서 제외됩니다. 기존 SSH 규칙이 "dst": ["autogroup:self"]만 있으면 태그된 서버에 접근할 수 없게 됩니다. "dst": ["tag:server"] 규칙을 태그 부여 전에 추가해야 합니다.
8. 참고 자료
- Tailscale GitHub Action 공식 문서 — 설정 방법, 인증 방식 비교, 예제 포함
- tailscale/github-action GitHub Repository — 소스 코드, 릴리스 노트, v4 최신 버전
- Tailscale SSH 공식 문서 — Tailscale SSH 설정, ACL 규칙, 동작 원리
- Tailscale Trust Credentials (OAuth) 문서 — OAuth Client 생성, Scope 설정, 감사 로그
- Tailscale ACL Tags 문서 — 태그 정의, tagOwners, 태그된 디바이스의 동작