---
title: "GitHub Actions self-hosted runner로 집 서버에 자동 배포하기"
description: "GitHub Actions self-hosted runner를 활용해 방화벽 설정 없이 집 서버에 안전한 자동 배포 파이프라인을 구축하는 방법을 소개합니다. 운영 디렉토리 직접 배포 패턴과 게이트 설계, 타임아웃 및 동시성 제어 등 실전에서 마주하는 함정을 해결하며 견고한 배포 환경을 만드는 핵심 노하우를 정리했습니다"
date: 2026-06-08
updated: 2026-06-08T09:00:00.000Z
tags: [cicd, github-actions, docker, devops]
canonical: https://blog.wooncloud.com/posts/github-actions-self-hosted-runner
---

![GitHub Actions self-hosted runner로 집 서버에 자동 배포하기](/images/posts/github-actions-self-hosted-runner/e268973b-5726-44ba-ba9f-60b0143fbdc7.webp)

## 문제: 클라우드 러너는 내 집 서버에 닿지 못한다

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시간 만료이므로 그 자리에서 바로 쓴다.

```bash
# 러너 전용 디렉토리 (운영 클론과 분리)
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_TOKEN
```

`config.sh` 는 러너 이름, 라벨, 작업 디렉토리(`_work`)를 묻는다. 라벨은 workflow 의 `runs-on` 에서 러너를 지목하는 데 쓴다. 기본 `self-hosted` 외에 `homelab` 같은 커스텀 라벨을 붙여 두면 러너가 여러 대일 때 골라 쓸 수 있다.

붙였으면 실행한다.

```bash
./run.sh
```

이건 포그라운드 실행이라 터미널을 닫으면 죽는다. 실제 운영에서는 서비스로 등록해 부팅 시 자동 기동·상시 실행되게 한다. 러너는 그 자체로 systemd 서비스 설치 스크립트를 제공한다.

```bash
# 서비스 설치 + 기동 (러너 디렉토리에서)
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`)로 들어가 거기서 직접 갱신·배포한다.

```text
/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` 를 쓴다.

```bash
git fetch origin main
git reset --hard origin/main
```

`pull` 은 로컬에 untracked 변경이나 갈라진 커밋이 있으면 merge 충돌로 멈춘다. 배포 디렉토리는 사람이 손대지 않는 곳이라, 로컬 상태가 어떻든 **무조건 origin/main 과 똑같이** 맞추는 게 맞다. `reset --hard` 는 워킹트리를 origin/main 으로 강제 정렬한다. 단, `.env` 처럼 `.gitignore` 에 든 untracked 파일은 `reset --hard` 가 건드리지 않으므로 그대로 살아남는다. (트래킹되는 파일을 사람이 임의 수정해 두면 날아가니, 운영 디렉토리는 절대 직접 편집하지 않는다는 규율이 전제다.)

## 기본 workflow

저장소 `.github/workflows/deploy.yml`.

```yaml
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` 으로 즉시 실패한다. 배포가 데몬 상태에 의존하면 안 된다. 배포 초반에 데몬을 기동하고, 소켓이 응답할 때까지 짧게 기다린다.

```yaml
      - 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 1
```

`docker 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` 코어유틸로 감싼다.

```yaml
      - name: Build images
        timeout-minutes: 20
        run: |
          cd "$APP_DIR"
          # compose 호출 자체를 강제 상한 — 초과 시 124 로 종료
          timeout 1200 docker compose build
```

job/스텝 timeout 은 GitHub 이 job 을 끊지만, 끊긴 자식 프로세스가 항상 깔끔히 정리되는 건 아니다. `timeout(1)` 으로 명령을 직접 감싸면 그 프로세스에 확실히 신호가 가서 더 깔끔하다. 핵심은 *어떤 단계도 무한히 매달리지 못하게* 백스톱을 겹쳐 두는 것이다.

### 3. concurrency 로 동시 배포 직렬화

`main` 에 짧은 간격으로 두 번 push 하면 배포 job 이 둘 뜬다. 같은 운영 디렉토리에서 동시에 `git reset --hard` 와 `docker compose up` 이 엇갈리면 워킹트리가 반쯤 갱신된 상태로 빌드되거나 컨테이너가 꼬인다. workflow 의 `concurrency` 블록이 이를 막는다.

```yaml
concurrency:
  group: deploy-production
  cancel-in-progress: true
```

같은 `group` 의 실행은 동시에 하나만 진행된다. `cancel-in-progress: true` 면 새 push 가 들어올 때 진행 중이던 오래된 배포를 취소하고 최신 것만 남긴다 — 어차피 최신 커밋만 배포하면 되므로 합리적이다. 중간 배포가 끊기지 않고 끝까지 가야 한다면 `false` 로 둬서 대기열에 줄 세운다. 어느 쪽이든 핵심은 같은 디렉토리에서 두 배포가 **절대 겹치지 않게** 하는 것이다.

### 4. 마이그레이션은 컨테이너 밖, 호스트에서

프로덕션 런타임 이미지는 보통 최소화한다. 멀티스테이지 빌드로 빌드 산출물만 담은 standalone/slim 이미지에는 ORM·마이그레이션 CLI 자체가 들어 있지 않은 경우가 많다. 그 컨테이너 안에서 `migrate` 를 부르면 명령을 못 찾는다.

DB 마이그레이션은 런타임 컨테이너가 아니라 **호스트(운영 디렉토리)** 에서 돌린다. 컨테이너를 새로 띄우기 **전에**, 호스트의 마이그레이션 도구로 스키마를 먼저 반영한다. 새 이미지가 새 스키마를 기대하기 때문이다.

```yaml
      - 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

위 조각을 합친 일반화 버전이다.

```yaml
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
```

러너 등록·서비스화 명령은 앞 절에 정리했다. 한 번 더 모아 두면.

```bash
# 등록
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 저장소 전용**이다. 이 다섯 가지만 지키면, 집 서버 한 대로 충분히 견고한 자동 배포 파이프라인이 선다.
