
깃허브 공개 저장소에 SSH 개인키를 워크플로 파일에 그대로 적어 넣으면, git log로 과거 커밋만 거슬러 올라가도 키 전체가 그대로 남아 있습니다. 한 번 커밋된 값은 이후 삭제해도 히스토리에 흔적이 남기 때문에, 키를 되돌리는 게 아니라 폐기하고 새로 발급하는 것이 유일한 해결책입니다. GitHub Actions로 배포 자동화할 때 가장 먼저 손대야 할 부분이 바로 이 시크릿 관리이고, 이 글에서는 appleboy/ssh-action을 기준으로 실제 동작하는 워크플로와 함께 어떤 지점에서 이 방식이 통하지 않는지도 같이 짚어보겠습니다.
GitHub Actions로 배포 자동화할 때 시크릿부터 점검해야 하는 이유
워크플로 yml 파일은 저장소를 클론하는 누구나 볼 수 있는 텍스트 파일입니다. 여기에 SSH 개인키나 서버 접속 정보를 직접 적으면, 저장소가 public이든 private이든 협업자·CI 로그·포크(fork) 과정에서 노출될 가능성이 생깁니다.
GitHub는 이 문제를 Repository Secrets 기능으로 해결합니다. Settings → Secrets and variables → Actions에서 값을 등록하면, GitHub가 libsodium 기반 sealed box 방식으로 값을 암호화해 저장하고, 워크플로 실행 중에만 복호화해 환경 변수처럼 주입합니다. 이 값은 잡(job) 로그에 그대로 찍히면 자동으로 ***로 마스킹되는데, 이 마스킹 처리 방식은 시크릿 사용법 공식 문서에 조건과 함께 안내되어 있습니다.
다만 마스킹은 값 자체를 문자열로 출력할 때만 동작하고, base64 인코딩을 하거나 값을 변형해서 출력하면 우회될 수 있습니다. 스크립트 안에서 시크릿 값을 가공해 echo하는 습관은 피하는 편이 안전합니다.
SSH 키 쌍을 만들고 GitHub Secrets에 등록하는 절차
배포 전용 키는 개인 로그인 키와 분리하는 것이 원칙입니다. 배포 서버에만 권한이 한정된 별도 키 쌍을 로컬에서 생성합니다.
ssh-keygen -t ed25519 -C "deploy-key" -f ./deploy_key -N ""

ed25519 알고리즘은 RSA 대비 키 길이가 짧으면서도 동등한 안전성을 제공하는 방식으로, OpenSSH 6.5 이상에서 지원됩니다. 생성된 deploy_key.pub는 배포 대상 서버의 ~/.ssh/authorized_keys에 추가하고, 개인키인 deploy_key는 GitHub 저장소 Secrets에 DEPLOY_SSH_KEY라는 이름으로 등록합니다.
이때 함께 등록해 둘 값은 아래 네 가지입니다.
| Secret 이름 | 값 |
|---|---|
| DEPLOY_HOST | 배포 서버 IP 또는 도메인 |
| DEPLOY_USER | 배포용 계정명 |
| DEPLOY_SSH_KEY | 위에서 만든 개인키 전체 내용 |
| DEPLOY_PORT | SSH 포트(기본 22가 아니라면 필수) |
로컬에 남은 deploy_key 파일은 Secrets 등록이 끝나면 바로 삭제하는 편이 좋습니다. 디스크에 평문 키가 오래 남아 있을수록 노출 경로가 늘어납니다.
appleboy/ssh-action으로 배포 워크플로 작성하기
.github/workflows/deploy.yml에 아래처럼 작성하면 main 브랜치에 푸시할 때마다 서버에 SSH로 접속해 지정한 스크립트를 실행합니다.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add known hosts
run: ssh-keyscan -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }}
script: |
cd /var/www/blog-autoposter
git pull origin main
docker compose up -d --build
Actions 탭에서 실행 결과를 열어보면, script에 적은 명령이 순서대로 실행되고 각 줄의 표준 출력이 로그에 그대로 나타납니다. git pull이 이미 최신 상태라면 Already up to date.가, 새 커밋이 반영됐다면 변경된 파일 목록이 찍히는 식입니다. 마지막 스텝이 0이 아닌 종료 코드를 반환하면 잡 전체가 실패 처리되므로, docker compose up -d --build가 실패했는데도 배포가 “성공”으로 표시되는 일은 없습니다.
known_hosts 검증을 생략하면 안 되는 이유
위 예시에 ssh-keyscan 스텝을 넣은 이유가 여기 있습니다. SSH 프로토콜은 클라이언트가 서버의 공개키 지문을 known_hosts에 등록해 두고, 접속할 때마다 이 지문을 대조해 중간자 공격을 막는 구조로 설계되어 있습니다.

호스트 키 검증을 생략(StrictHostKeyChecking no)한 채로 배포 스크립트를 짜면 이 검증 절차 자체가 사라집니다. GitHub 호스팅 러너는 매번 새 가상 머신에서 실행되기 때문에 known_hosts가 비어 있는 상태로 시작하는데, 여기서 검증을 끄는 대신 ssh-keyscan으로 서버의 실제 공개키를 미리 받아 known_hosts에 추가해 두면 검증을 유지한 채로 자동화할 수 있습니다. 서버의 호스트 키가 바뀌었을 때 배포가 실패하는 것은 번거로움이 아니라, 의도치 않은 서버로 접속을 시도했을 때 걸려야 할 안전장치가 정상 동작한 것입니다.
이 방식이 통하지 않는 경우와 대안
이 구성이 깔끔하게 동작하려면 배포 서버가 인터넷에서 SSH 포트로 접근 가능해야 합니다. 서버가 사내망이나 VPN 뒤에 있어 특정 IP만 방화벽에서 허용하는 구조라면 문제가 생깁니다. GitHub 호스팅 러너는 고정 IP가 아니라 메타 API로 공개되는 IP 대역을 사용하는 유동적인 실행 환경이라, 방화벽에 개별 IP를 화이트리스트로 등록하는 방식이 잘 맞지 않습니다.
이럴 때는 세 가지 방식을 놓고 실제 인프라 조건에 맞춰 고르는 편이 합리적입니다.
| 방식 | 설정 난이도 | 키 관리 부담 | 사설망 서버 대응 |
|---|---|---|---|
| appleboy/ssh-action + Secrets | 낮음 | 배포 키 발급·폐기 관리 필요 | 어려움(공인 IP 노출 필요) |
| 자체 호스팅 러너(self-hosted runner) | 중간 | 러너가 내부망에 상주, 별도 키 불필요할 수도 있음 | 쉬움 |
| 클라우드 제공사 배포 서비스(CodeDeploy 등) | 높음 | IAM 역할 기반, SSH 키 자체가 불필요 | 해당 클라우드 한정 |
사내망 서버라면 GitHub가 공식 지원하는 자체 호스팅 러너를 내부망에 설치해, 러너가 서버와 같은 네트워크 안에서 직접 통신하게 하는 구성이 SSH 포트를 외부에 열지 않아도 되는 만큼 더 안전합니다. AWS 인프라라면 SSH 키 자체를 없애고 IAM 역할로 인증하는 CodeDeploy 조합도 고려할 만합니다.
GitHub Actions 배포 자동화, 이 체크리스트부터 확인하세요
정리하면 다음 네 가지만 지켜도 시크릿 노출 위험은 크게 줄어듭니다. 배포 전용 SSH 키를 별도로 발급하고, 값은 반드시 Repository Secrets에 등록하며, ssh-keyscan으로 known_hosts를 채워 호스트 검증을 유지하고, 서버가 외부에서 접근 가능한 구조인지 먼저 확인하는 것입니다.
지금 바로 할 수 있는 다음 단계는 기존 워크플로 파일에 평문으로 적힌 값이 있는지 검색해 보는 일입니다. 만약 과거 커밋에 키가 한 번이라도 남아 있었다면, 값을 지우는 것으로는 부족하고 서버의 authorized_keys에서 해당 키를 제거한 뒤 새 키 쌍으로 교체하는 것이 유일하게 안전한 조치입니다.
