GitHub Actions self-hosted runner로 집 서버에 자동 배포하기
GitHub Actions self-hosted runner를 활용해 방화벽 설정 없이 집 서버에 안전한 자동 배포 파이프라인을 구축하는 방법을 소개합니다. 운영 디렉토리 직접 배포 패턴과 게이트 설계, 타임아웃 및 동시성 제어 등 실전에서 마주하는 함정을 해결하며 견고한 배포 환경을 만드는 핵심 노하우를 정리했습니다

문제: 클라우드 러너는 내 집 서버에 닿지 못한다
GitHub Actions 의 기본 러너는 GitHub 이 관리하는 클라우드 VM 이다. 이 VM 에서 집 서버로 SSH 배포를 하려면, 집 서버가 인바운드 연결을 받아줘야 한다. 그런데 집 서버는 대개 공유기(NAT) 뒤에 있고, 포트포워딩을 열거나 고정 IP 를 받거나 VPN 을 뚫지 않는 한 외부에서 직접 닿을 수 없다. 보안상 인바운드를 막아두는 게 정상이기도 하다.
여기서 발상을 뒤집는다. 외부에서 서버로 들어가는 대신, 서버 자신이 GitHub Actions runner 가 되게 한다. self-hosted runner 는 GitHub 으로 아웃바운드(443) 롱폴링 연결만 맺는다. 인바운드 포트를 하나도 열지 않아도, GitHub 에 작업이 생기면 러너가 그걸 받아 실행한다. main 에 push 하면, 집 서버가 스스로 최신 코드를 받아 빌드하고 컨테이너를 띄운다.
이 글은 그 파이프라인을 처음부터 구성한다. 등록 → 운영 디렉토리 직접 배포 패턴 → workflow → 실전 함정 순으로 간다. 함정 처리가 이 구조의 핵심이므로 거기에 가장 많은 분량을 쓴다.
self-hosted runner 등록
저장소의 Settings → Actions → Runners → New self-hosted runner 에서 OS/아키텍처를 고르면 등록 명령을 준다. 토큰은 약 1시간 만료이므로 그 자리에서 바로 쓴다.
# 러너 전용 디렉토리 (운영 클론과 분리)
mkdir -p ~/actions-runner && cd ~/actions-runner
# 최신 러너 바이너리 내려받기 (버전/해시는 등록 화면 값 사용)
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.999.0/actions-runner-linux-x64-2.999.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
# 저장소에 러너 연결 (URL/TOKEN 은 등록 화면 값)
./config.sh --url https://github.com/OWNER/REPO --token REGISTRATION_TOKENconfig.sh 는 러너 이름, 라벨, 작업 디렉토리(_work)를 묻는다. 라벨은 workflow 의 runs-on 에서 러너를 지목하는 데 쓴다. 기본 self-hosted 외에 homelab 같은 커스텀 라벨을 붙여 두면 러너가 여러 대일 때 골라 쓸 수 있다.
붙였으면 실행한다.
./run.sh이건 포그라운드 실행이라 터미널을 닫으면 죽는다. 실제 운영에서는 서비스로 등록해 부팅 시 자동 기동·상시 실행되게 한다. 러너는 그 자체로 systemd 서비스 설치 스크립트를 제공한다.
# 서비스 설치 + 기동 (러너 디렉토리에서)
sudo ./svc.sh install
sudo ./svc.sh start
# 상태 확인 / 중지
sudo ./svc.sh status
sudo ./svc.sh stop러너는 가능하면 전용 사용자로 돌리고, 그 사용자를
docker그룹에 넣어 sudo 없이docker compose를 호출할 수 있게 한다. root 로 러너를 돌리는 건 피한다. workflow 파일을 push 할 수 있는 사람은 러너 위에서 임의 명령을 실행할 수 있으므로, public 저장소에는 self-hosted runner 를 붙이지 마라. fork PR 이 당신 서버에서 코드를 실행하게 된다. private 저장소 전용으로 쓴다.
운영 디렉토리에서 직접 배포하는 패턴
여기가 일반적인 CI 와 갈리는 지점이다. 보통 workflow 는 actions/checkout 으로 러너 워크스페이스(_work/...)에 코드를 새로 받아 거기서 빌드한다. 셀프호스팅 배포에서는 그렇게 하지 않는다. 대신 서버에 미리 둔 실제 운영 클론 디렉토리(예: /srv/app)로 들어가 거기서 직접 갱신·배포한다.
/srv/app
├── .env # 운영 시크릿 — git 에 없음, 이 디렉토리에만 존재
├── docker-compose.yml
├── .git/ # origin = 배포 대상 저장소
└── ... (소스 트리)이유는 .env 다. docker compose 는 같은 디렉토리의 .env 를 읽어 변수를 주입한다. 운영 시크릿(DB 비밀번호, API 키 등)은 git 에 절대 커밋하지 않으므로 actions/checkout 으로 받은 깨끗한 워크스페이스에는 .env 가 없다. 운영 디렉토리에는 한 번 직접 채워 둔 .env 가 영속한다. 그 디렉토리에서 배포하면 시크릿 주입 문제가 그냥 사라진다.
git pull 대신 fetch + reset --hard 를 쓴다.
git fetch origin main
git reset --hard origin/mainpull 은 로컬에 untracked 변경이나 갈라진 커밋이 있으면 merge 충돌로 멈춘다. 배포 디렉토리는 사람이 손대지 않는 곳이라, 로컬 상태가 어떻든 무조건 origin/main 과 똑같이 맞추는 게 맞다. reset --hard 는 워킹트리를 origin/main 으로 강제 정렬한다. 단, .env 처럼 .gitignore 에 든 untracked 파일은 reset --hard 가 건드리지 않으므로 그대로 살아남는다. (트래킹되는 파일을 사람이 임의 수정해 두면 날아가니, 운영 디렉토리는 절대 직접 편집하지 않는다는 규율이 전제다.)
기본 workflow
저장소 .github/workflows/deploy.yml.
name: deploy
on:
push:
branches: [main]
# 동시 배포 직렬화: 새 push 가 오면 진행 중이던 건 취소
concurrency:
group: deploy-production
cancel-in-progress: true
jobs:
deploy:
runs-on: [self-hosted, homelab]
timeout-minutes: 30 # 러너 무한 점유 방지 백스톱
env:
APP_DIR: /srv/app
steps:
- name: Sync working tree to origin/main
run: |
cd "$APP_DIR"
git fetch origin main
git reset --hard origin/main
- name: Install dependencies
run: |
cd "$APP_DIR"
# 게이트에서 쓰는 도구가 필요하면 여기서 (예: pnpm install --frozen-lockfile)
pnpm install --frozen-lockfile
# --- 게이트: 빌드보다 먼저. 깨지면 무거운 빌드 전에 중단 ---
- name: Typecheck
run: cd "$APP_DIR" && pnpm typecheck
- name: Test
run: cd "$APP_DIR" && pnpm test
- name: Lint
run: cd "$APP_DIR" && pnpm lint
# --- 빌드 + 기동 ---
- name: Build images
timeout-minutes: 20 # 빌드 hang 백스톱
run: cd "$APP_DIR" && docker compose build
- name: Up
run: cd "$APP_DIR" && docker compose up -d --remove-orphans핵심 설계는 게이트가 빌드 앞에 있다는 것이다. typecheck/test/lint 가 깨지면 도커 이미지 빌드(보통 가장 무겁고 느린 단계)에 진입하기도 전에 job 이 멈춘다. 깨진 코드를 빌드하느라 수 분을 낭비하지 않고, 운영 컨테이너는 이전 정상 상태 그대로 계속 돈다. up 단계까지 갔다는 건 게이트와 빌드를 모두 통과했다는 뜻이다.
runs-on: [self-hosted, homelab] 은 두 라벨을 모두 가진 러너에서만 돈다. 클라우드 러너로 잘못 라우팅되는 일을 막는다.
실전 함정
배포 골격 자체는 단순하다. 실제로 깨지는 건 거의 다 아래 다섯 가지다.
1. Docker 데몬이 꺼져 있으면 모든 게 즉시 실패
집 서버는 재부팅·정전·OS 업데이트로 종종 껐다 켜진다. Docker 데몬이 아직 안 올라온 상태에서 docker compose 를 부르면 Cannot connect to the Docker daemon 으로 즉시 실패한다. 배포가 데몬 상태에 의존하면 안 된다. 배포 초반에 데몬을 기동하고, 소켓이 응답할 때까지 짧게 기다린다.
- name: Ensure Docker daemon is up
run: |
# 데몬이 죽어 있으면 기동 시도 (systemd 환경)
if ! docker info >/dev/null 2>&1; then
sudo systemctl start docker || true
fi
# 소켓이 응답할 때까지 대기 (최대 ~60초)
for i in $(seq 1 30); do
if docker info >/dev/null 2>&1; then
echo "docker daemon ready"
exit 0
fi
echo "waiting for docker daemon... ($i)"
sleep 2
done
echo "docker daemon did not become ready in time" >&2
exit 1docker info 가 데몬 헬스체크다. 성공하면 곧장 나가고, 안 되면 무한 대기 대신 30회(약 60초) 만에 명확히 실패시킨다. sudo systemctl start docker 를 무인 실행하려면 러너 사용자에게 그 명령만 NOPASSWD 로 허용하는 sudoers 룰을 좁게 둔다.
2. 빌드·이미지 pull 이 hang 한다
레지스트리가 느리거나, 베이스 이미지 pull 중 네트워크가 끊기거나, 빌드 한 단계가 무한 대기에 빠지면 스텝이 끝나지 않는다. self-hosted runner 는 한 번에 한 job 만 돌리는 게 보통이라, hang 한 job 하나가 러너 전체를 무기한 점유해 이후 모든 배포를 막는다. timeout 을 다층으로 둔다.
- job 레벨
timeout-minutes로 전체 상한. - 빌드처럼 hang 위험이 큰 스텝에는 스텝 레벨
timeout-minutes를 따로.
명령 단위로 더 조이고 싶으면 timeout 코어유틸로 감싼다.
- name: Build images
timeout-minutes: 20
run: |
cd "$APP_DIR"
# compose 호출 자체를 강제 상한 — 초과 시 124 로 종료
timeout 1200 docker compose buildjob/스텝 timeout 은 GitHub 이 job 을 끊지만, 끊긴 자식 프로세스가 항상 깔끔히 정리되는 건 아니다. timeout(1) 으로 명령을 직접 감싸면 그 프로세스에 확실히 신호가 가서 더 깔끔하다. 핵심은 어떤 단계도 무한히 매달리지 못하게 백스톱을 겹쳐 두는 것이다.
3. concurrency 로 동시 배포 직렬화
main 에 짧은 간격으로 두 번 push 하면 배포 job 이 둘 뜬다. 같은 운영 디렉토리에서 동시에 git reset --hard 와 docker compose up 이 엇갈리면 워킹트리가 반쯤 갱신된 상태로 빌드되거나 컨테이너가 꼬인다. workflow 의 concurrency 블록이 이를 막는다.
concurrency:
group: deploy-production
cancel-in-progress: true같은 group 의 실행은 동시에 하나만 진행된다. cancel-in-progress: true 면 새 push 가 들어올 때 진행 중이던 오래된 배포를 취소하고 최신 것만 남긴다 — 어차피 최신 커밋만 배포하면 되므로 합리적이다. 중간 배포가 끊기지 않고 끝까지 가야 한다면 false 로 둬서 대기열에 줄 세운다. 어느 쪽이든 핵심은 같은 디렉토리에서 두 배포가 절대 겹치지 않게 하는 것이다.
4. 마이그레이션은 컨테이너 밖, 호스트에서
프로덕션 런타임 이미지는 보통 최소화한다. 멀티스테이지 빌드로 빌드 산출물만 담은 standalone/slim 이미지에는 ORM·마이그레이션 CLI 자체가 들어 있지 않은 경우가 많다. 그 컨테이너 안에서 migrate 를 부르면 명령을 못 찾는다.
DB 마이그레이션은 런타임 컨테이너가 아니라 호스트(운영 디렉토리) 에서 돌린다. 컨테이너를 새로 띄우기 전에, 호스트의 마이그레이션 도구로 스키마를 먼저 반영한다. 새 이미지가 새 스키마를 기대하기 때문이다.
- name: Apply DB migrations (host-side)
run: |
cd "$APP_DIR"
# 런타임 이미지엔 마이그레이션 CLI 가 없을 수 있으므로 호스트에서 실행
# 예: pnpm db:migrate / npx prisma migrate deploy / alembic upgrade head 등
pnpm db:migrate순서는 게이트 → 마이그레이션 → 빌드 → up 이다. 마이그레이션이 호스트 도구체인에 의존한다면, Install dependencies 스텝에서 그 도구가 깔려 있어야 한다. 무중단으로 가려면 마이그레이션은 가능한 한 하위호환(추가형)으로 설계해, 구버전 컨테이너가 잠깐 새 스키마와 공존해도 깨지지 않게 한다.
5. 게이트를 빌드 앞에 둬라 (다시 강조)
위 workflow 에 이미 반영했지만 따로 짚는다. 게이트(typecheck/test/lint)를 도커 빌드 뒤에 두는 흔한 실수를 하면, 깨진 코드도 일단 이미지부터 빌드하고 나서야 실패를 안다. 도커 빌드는 파이프라인에서 가장 비싼 단계다. 게이트를 앞에 두면 깨진 push 는 빌드에 진입하지 못한 채 수 초~수십 초 만에 떨어지고, 운영 컨테이너는 직전 정상 이미지로 계속 돈다. 빠른 실패가 곧 안전한 무중단이다.
전체 deploy.yml
위 조각을 합친 일반화 버전이다.
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: true
jobs:
deploy:
runs-on: [self-hosted, homelab]
timeout-minutes: 30
env:
APP_DIR: /srv/app
steps:
- name: Sync working tree to origin/main
run: |
cd "$APP_DIR"
git fetch origin main
git reset --hard origin/main
- name: Ensure Docker daemon is up
run: |
if ! docker info >/dev/null 2>&1; then
sudo systemctl start docker || true
fi
for i in $(seq 1 30); do
if docker info >/dev/null 2>&1; then
echo "docker daemon ready"; exit 0
fi
echo "waiting for docker daemon... ($i)"; sleep 2
done
echo "docker daemon did not become ready in time" >&2; exit 1
- name: Install dependencies
run: |
cd "$APP_DIR"
pnpm install --frozen-lockfile
# --- 게이트: 빌드보다 먼저 ---
- name: Typecheck
run: cd "$APP_DIR" && pnpm typecheck
- name: Test
run: cd "$APP_DIR" && pnpm test
- name: Lint
run: cd "$APP_DIR" && pnpm lint
# --- 마이그레이션: 호스트에서, 빌드/기동 전에 ---
- name: Apply DB migrations (host-side)
run: |
cd "$APP_DIR"
pnpm db:migrate
# --- 빌드 + 무중단 기동 ---
- name: Build images
timeout-minutes: 20
run: |
cd "$APP_DIR"
timeout 1200 docker compose build
- name: Up
run: |
cd "$APP_DIR"
docker compose up -d --remove-orphans러너 등록·서비스화 명령은 앞 절에 정리했다. 한 번 더 모아 두면.
# 등록
cd ~/actions-runner
./config.sh --url https://github.com/OWNER/REPO --token REGISTRATION_TOKEN
# 서비스로 상시 실행
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status정리
집 서버를 self-hosted runner 로 만들면, 인바운드를 하나도 열지 않고 main 에 push 한 번으로 게이트 → 마이그레이션 → 빌드 → 무중단 기동까지 자동으로 흐른다. 서버가 GitHub 으로 아웃바운드 연결만 맺으면 되니 NAT·방화벽 뒤에서도 그대로 동작한다.
골격은 단순하다. actions/checkout 대신 운영 디렉토리에서 fetch + reset --hard, 게이트를 빌드 앞에, 그리고 docker compose build && up. 견고함은 함정 몇 개를 처리하는 데서 나온다.
- Docker 데몬을 기동·대기시켜 콜드 부팅에서도 배포가 선다.
- job·스텝·명령에 timeout 을 겹쳐 hang 이 러너를 영구 점유하지 못하게 한다.
concurrency로 동시 배포를 직렬화한다.- 마이그레이션은 런타임 컨테이너가 아니라 호스트에서 돌린다.
- 게이트를 빌드 앞에 둬 실패를 빠르고 싸게 만든다.
그리고 보안 전제 하나: self-hosted runner 는 private 저장소 전용이다. 이 다섯 가지만 지키면, 집 서버 한 대로 충분히 견고한 자동 배포 파이프라인이 선다.