study/server

[Ubuntu] 05. 홈 서버 자동화 - GitHub Actions로 Docker 자동 배포 구축

렁치 2026. 7. 13. 02:22

지난 글까지는 ASP.NET Core 게임 서버를 Ubuntu 홈 서버에 직접 배포했고, EF Core Migration과 SQL Server 문제까지 정리했다.

그때 배포는 이런 식이었다.

cd ~/docker/game/<REPOSITORY>
git pull

docker build -t survivalgame:test .

cd ~/docker/compose
docker compose up -d --force-recreate survivalgame

처음에는 이 방식도 나쁘지 않았다. 서버에 직접 들어가서 로그를 보고, 컨테이너 상태를 확인하면서 배포하니까 오히려 확실하다는 느낌도 있었다. 그런데 몇 번 반복해 보니 바로 단점이 보였다.

코드를 조금 고칠 때마다 서버에 접속하고, git pull 하고, 이미지 빌드하고, Compose를 다시 실행해야 했다. 실수로 서버 브랜치를 잘못 보고 있으면 로컬에서 수정한 코드가 올라간 줄 알았는데 서버는 예전 코드를 계속 실행하는 상황도 생겼다.

그래서 이번에는 GitHub Actions로 배포를 자동화했다.


오늘 목표

오늘 목표는 단순하다.

로컬에서 main 브랜치로 push
        ↓
GitHub Actions 실행
        ↓
SSH로 홈 서버 접속
        ↓
서버 저장소를 main 기준으로 동기화
        ↓
Docker 이미지 빌드
        ↓
Docker Compose 재실행
        ↓
내부/외부 응답 확인

최종적으로는 아래 명령을 직접 입력하지 않는 것이 목표였다.

git pull
docker build -t survivalgame:test .
docker compose up -d --force-recreate survivalgame

현재 서버 구조

현재 서버는 이미 아래 구조로 동작하고 있다.

사용자 브라우저
    ↓
Cloudflare
    ↓
Nginx
    ↓
127.0.0.1:18080
    ↓
survivalgame 컨테이너
    ↓
mssql-db 컨테이너

앱 컨테이너는 외부에 직접 열려 있지 않고, 서버 내부의 127.0.0.1:18080으로만 연결된다.

즉 GitHub Actions가 해야 하는 일은 Nginx나 Cloudflare를 다시 만드는 것이 아니라, 서버에 접속해서 앱 컨테이너만 새 이미지로 갈아끼우는 것이다.


GitHub Actions에 필요한 Secret

먼저 GitHub 저장소의 Actions Secret을 등록했다.

SERVER_HOST       = <SSH_HOST>
SERVER_USER       = <SERVER_USER>
SERVER_PORT       = <SSH_PORT>
SERVER_SSH_KEY    = GitHub Actions 전용 private key
DEPLOY_PATH       = /home/<USER>/docker/game/<REPOSITORY>
COMPOSE_PATH      = /home/<USER>/docker/compose

여기서 중요한 건 SERVER_SSH_KEY다.

처음에는 공개키와 개인키가 헷갈릴 수 있다.

공개키(.pub)
    → 서버의 ~/.ssh/authorized_keys에 넣는다.

개인키
    → GitHub Actions Secret에 넣는다.

GitHub Actions는 서버에 접속하는 클라이언트 역할을 한다. 그래서 개인키가 필요하다.


배포용 SSH 키 생성

로컬 Windows에서 배포 전용 키를 만들었다.

ssh-keygen -t ed25519 -C "github-actions-survivalgame" -f $HOME\.ssh\survivalgame_actions

생성된 파일은 두 개다.

survivalgame_actions      ← 개인키
survivalgame_actions.pub  ← 공개키

공개키는 서버에 추가한다.

nano ~/.ssh/authorized_keys

권한도 확인했다.

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

chmod는 성공하면 아무 메시지도 출력하지 않는다. 처음에는 이것도 살짝 헷갈렸는데, 리눅스 명령어는 성공했을 때 조용한 경우가 많다.


첫 번째 workflow

처음 구성은 main 브랜치에 push되면 바로 배포하는 방식이었다.

name: Deploy SurvivalGame

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to home server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          port: ${{ secrets.SERVER_PORT }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            set -e

            cd ${{ secrets.DEPLOY_PATH }}
            git checkout main
            git pull origin main

            docker build -t survivalgame:test .

            cd ${{ secrets.COMPOSE_PATH }}
            docker compose up -d --force-recreate survivalgame

            curl -I http://127.0.0.1:18080
            curl -I https://<DOMAIN>

이 정도만 해도 기본 자동 배포는 된다. 하지만 실제로 돌려보니 바로 몇 가지 문제가 나왔다.


첫 번째 문제: missing server host

처음 만난 오류는 이것이었다.

Error: missing server host

이건 서버가 죽었다는 뜻이 아니었다. GitHub Actions가 SSH 접속을 시작하기도 전에 host 값이 비어 있다는 뜻이었다.

원인은 단순했다. Secret을 하나만 만들어서 그 안에 여러 값을 넣으려고 했다.

GitHub Actions의 Secret은 값 묶음이 아니라 환경변수처럼 각각 이름으로 참조된다.

즉 아래처럼 따로 만들어야 한다.

SERVER_HOST
SERVER_USER
SERVER_PORT
SERVER_SSH_KEY
DEPLOY_PATH
COMPOSE_PATH

이 부분을 고치고 다시 실행했다.


두 번째 문제: private key 형식

다음 오류는 이것이었다.

ssh.ParsePrivateKey: ssh: no key found

이번에는 SERVER_SSH_KEY에 들어간 값이 문제였다.

GitHub Secret에는 아래처럼 생긴 개인키 전체가 들어가야 한다.

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

처음 줄과 마지막 줄까지 모두 들어가야 한다. .pub 파일 내용을 넣으면 안 된다.

이 문제를 고친 뒤에는 로컬에서 같은 키로 접속 테스트를 했다.

ssh -i $HOME\.ssh\survivalgame_actions -p <SSH_PORT> <SERVER_USER>@<SSH_HOST>

로컬에서 접속이 되면 최소한 키 자체는 정상이다.


세 번째 문제: Cloudflare Proxy와 SSH

한 번은 SERVER_HOST를 일반 도메인으로 넣었다가 GitHub Actions가 Cloudflare 프록시 IP로 SSH 접속을 시도했다.

dial tcp <CLOUDFLARE_IP>:<SSH_PORT>: i/o timeout

이때 다시 정리했다.

Cloudflare 주황 구름(Proxy)은 HTTP/HTTPS 트래픽을 프록시한다. 일반 SSH 접속은 그대로 통과하지 않는다.

그래서 SSH용 서브도메인은 반드시 DNS Only로 둬야 한다.

<SSH_HOST>  A  <PUBLIC_IP>  DNS only

웹 서비스용 도메인은 Proxy를 사용하고, SSH용 도메인은 DNS Only를 사용하는 식으로 역할을 분리했다.


네 번째 문제: private repository 인증

SSH 접속까지 해결했는데, 서버에서 git pull 단계가 막혔다.

fatal: could not read Username for 'https://github.com'

서버의 origin이 private repository를 HTTPS로 바라보고 있었고, 서버에는 GitHub 인증 정보가 없었다.

서버에 PAT나 GitHub 계정 정보를 저장할 수도 있지만, 그 방식은 마음에 들지 않았다. 배포 서버에는 가능하면 GitHub 인증 정보를 남기고 싶지 않았다.

그래서 방식을 바꿨다.

서버에 저장된 인증 정보에 의존하지 않음
        ↓
GitHub Actions의 임시 GITHUB_TOKEN 사용
        ↓
git fetch
        ↓
git reset --hard FETCH_HEAD

배포 때만 임시 토큰으로 fetch하고, 서버 워크트리를 main과 정확히 맞춘다.


최종 배포 방식

핵심 부분은 이런 형태로 정리했다.

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to home server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          port: ${{ secrets.SERVER_PORT }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          timeout: 120s
          command_timeout: 30m
          script: |
            set -eu

            cd "${{ secrets.DEPLOY_PATH }}"

            echo "Before deploy commit"
            git rev-parse --short HEAD

            git fetch "https://x-access-token:${{ github.token }}@github.com/<OWNER>/<REPOSITORY>.git" main
            git checkout main
            git reset --hard FETCH_HEAD

            echo "After deploy commit"
            git rev-parse --short HEAD

            docker build -t survivalgame:test .

            cd "${{ secrets.COMPOSE_PATH }}"
            docker compose up -d --force-recreate survivalgame

            curl -fsSI http://127.0.0.1:18080
            curl -fsSI https://<DOMAIN>

여기서 git reset --hard는 조심해야 한다.

서버의 배포 경로에서 직접 수정한 파일은 날아간다. 그래서 원칙을 정했다.

소스 수정은 로컬에서 한다.
커밋 후 push한다.
서버는 배포 산출물로만 사용한다.

서버 안의 .env, DB 데이터, 인증서, 백업 파일은 애초에 repository 밖에 둔다.


배포 전후 커밋 로그

배포 로그에 커밋 해시를 남기도록 했다.

echo "Before deploy commit"
git rev-parse --short HEAD

# fetch/reset

echo "After deploy commit"
git rev-parse --short HEAD

이렇게 해두면 나중에 문제가 생겼을 때 서버가 어떤 커밋을 실행 중인지 확인하기 쉽다.

Before deploy commit
7e788ba

After deploy commit
a242aef

이런 식으로 남는다.


Health Check

배포 후에는 내부 응답과 외부 응답을 나눠서 확인했다.

curl -fsSI http://127.0.0.1:18080
curl -fsSI https://<DOMAIN>

첫 번째는 Kestrel이 응답하는지 확인한다.

두 번째는 Cloudflare와 Nginx까지 거친 외부 경로를 확인한다.

internal check 실패
    → 앱 컨테이너 문제 가능성

external check만 실패
    → Nginx, Cloudflare, DNS 문제 가능성

나중에는 /healthz 같은 전용 health endpoint를 만드는 게 더 좋겠지만, 지금 단계에서는 루트 HTML 응답 확인만으로도 충분했다.


최종 구조

이번 작업이 끝난 뒤 배포 흐름은 이렇게 바뀌었다.

로컬 개발
    ↓
git push main
    ↓
GitHub Actions
    ↓
SSH 접속
    ↓
GITHUB_TOKEN으로 private repo fetch
    ↓
git reset --hard FETCH_HEAD
    ↓
Docker build
    ↓
Docker Compose 재실행
    ↓
Health Check

이제 서버에 직접 들어가서 매번 배포 명령을 입력하지 않아도 된다.


오늘 배운 점

자동 배포는 단순히 명령어를 자동으로 실행하는 일이 아니었다.

처음에는 git pull, docker build, docker compose up만 GitHub Actions에 넣으면 끝날 줄 알았다. 그런데 실제로는 SSH 키, Cloudflare DNS, private repository 인증, 서버 브랜치 상태, Docker 이미지 상태까지 같이 맞아야 했다.

특히 private repository 인증 문제를 서버에 저장된 GitHub 계정으로 해결하지 않고, Actions의 임시 토큰으로 처리한 점이 마음에 들었다. 서버에 불필요한 인증 정보를 남기지 않고도 배포가 가능해졌다.

앞으로 서버는 작업 공간이 아니라 실행 환경으로만 사용할 생각이다. 수정은 로컬에서 하고, 서버는 main 브랜치의 결과물을 실행한다. 이 원칙을 지키면 배포 상태가 훨씬 덜 꼬일 것 같다.


다음 작업

자동 배포는 성공했지만, 운영 서버에서는 코드만큼 데이터도 중요하다.

다음 작업은 MSSQL 백업이다.

백업은 만들 수 있는 것보다 복구할 수 있는지가 더 중요하니, 복구 테스트까지 같이 진행할 예정이다.