이채강

읽는 데 8분조회 4

MyHome CI/CD 배포 흐름 구축기 — GitHub Actions와 Docker Compose 기반 태그 배포

버전 1.0.0

작성 기준: 2026-09-20

개인 홈페이지 배포를 손으로 하고 있었다. 코드를 고치고, 시험을 돌리고, 서버에서 이미지를 만들고, 컨테이너를 다시 띄웠다. 하나씩 보면 어려운 일은 아닌데 순서를 하나 빼먹으면 운영에 바로 드러났다.

개발 화면은 빨리 보고 싶고, 운영은 천천히 내보내고 싶었다. 둘을 같은 작업 폴더와 같은 명령으로 다루면 어느 날 잘못 누른 한 번이 배포가 된다.

그래서 개발기는 현재 브랜치를 바로 보여 주되, 운영은 버전 태그가 올라왔을 때만 움직이게 만들었다. GitHub Actions 에서 코드를 검사하고, Oracle Cloud 서버의 self-hosted runner 가 태그를 받아 Docker Compose 로 띄운다.

이 글은 그 배포 흐름을 만들고, 실제로 몇 번 틀린 뒤 지금 모양으로 굳힌 과정이다.


1. 개발하는 곳과 운영하는 곳부터 나눴다

서버 한 대에서 개발기와 운영을 같이 돌린다. 그렇다고 같은 디렉터리를 쓰지는 않는다.

위치하는 일남아 있는 것
~/workspace/myhome개발 서버와 Git 저장소소스, 시험, Dockerfile, 배포 설정
~/MyHome운영 컨테이너 실행 위치.env, Compose 복사본, 올린 파일

개발 서버는 ~/workspace/myhome 에서 next dev 로 뜬다. 지금 체크아웃한 브랜치가 그대로 dev.leechis.dev 에 보인다. 기능 가지를 합치기 전에도 휴대폰과 다른 브라우저에서 실제 화면을 볼 수 있다.

운영 디렉터리에는 소스를 두지 않았다. 저장소의 infra/deploy.sh 가 Compose 설정과 운영 스크립트를 ~/MyHome 으로 복사한다. 비밀번호와 키가 있는 .env 는 건드리지 않는다.

설정은 Git 에 두고, 비밀값과 영속 데이터만 실행 위치에 둔 셈이다. 컨테이너를 새로 만들어도 PostgreSQL 볼륨과 storage/uploads 는 남는다.

DB 도 갈랐다. 서버는 하나지만 운영은 myhome, 개발은 myhome_dev 를 쓴다. 개발하다 글을 지우거나 브라우저 시험을 돌려도 운영 데이터에는 닿지 않는다.


2. 가지는 세 단계, 배포 문은 태그 하나

혼자 만드는 프로젝트지만 가지를 나눴다.

flowchart LR
  F["feature 브랜치"] -->|통합| D["dev"]
  D -->|배포 준비| M["main"]
  M -->|버전 태그| V["v0.25.0"]
  V --> O["운영 배포"]

feature/* 에서 기능 하나를 만든다. 로컬 검사와 개발 주소 확인이 끝나면 dev 에 합친다. 다음 배포에 넣을 것들이 모이면 main 으로 보낸다.

처음에는 main push를 배포 신호로 삼을까 했다. 그러면 합치는 순간 운영이 움직인다. CHANGELOG 를 고치거나 여러 기능을 한 판으로 묶을 틈이 없다.

그래서 main push는 검사만 한다. 실제 배포 문은 v0.25.0 같은 태그다.

flowchart TD
  P{"무엇을 push했나?"}
  P -->|feature| F["검사"]
  P -->|dev| D["검사"]
  P -->|main| M["검사"]
  P -->|v* tag| T["검사"]
  T --> DEP["배포"]

main 과 운영 사이에 사람이 여는 문을 하나 둔 것이다. 버전 태그를 올려야만 운영이 바뀌므로, 어느 코드가 나갔는지도 태그 하나로 설명된다.


3. CI 는 실제 PostgreSQL 과 브라우저까지 본다

GitHub-hosted Ubuntu runner 에서 검사를 돌린다. 순서는 이렇다.

  1. npm ci 로 잠금 파일 기준 의존성을 설치한다
  2. ESLint 를 돌린다
  3. Next.js 라우트 타입을 만들고 TypeScript 를 검사한다
  4. Vitest 단위 시험을 돌린다
  5. PostgreSQL 18 에 확장을 설치하고 마이그레이션과 seed를 넣는다
  6. 운영 빌드를 만든다
  7. Chromium 을 설치하고 Playwright 브라우저 시험을 돌린다
services:
  postgres:
    image: postgres:18-alpine

steps:
  - run: npm ci
  - run: npm run lint
  - run: npx next typegen
  - run: npx tsc --noEmit
  - run: npm test
  - run: npm run db:migrate
  - run: npm run db:seed
  - run: npm run build
  - run: npm run test:e2e

단위 시험만으로는 모르는 것이 있었다. SQL은 맞는데 실제 마이그레이션 순서가 틀릴 수 있고, 페이지는 그려지는데 관리자 동작이 깨질 수 있다. 그래서 CI 안에 PostgreSQL 서비스와 브라우저를 같이 넣었다.

브라우저 시험이 실패하면 Playwright 보고서와 test-results 를 7일 동안 artifact로 남긴다. 성공했을 때보다 실패했을 때 볼 것이 더 많아야 한다.

태그를 먼저 올렸다가 한 번 되돌렸다

2026-09-09에는 v0.11.0 태그를 올린 뒤에야 브라우저 시험이 깨진 것을 알았다. 태그를 지우고, 고친 커밋에 다시 붙여야 했다.

순서가 거꾸로였다. 그 뒤부터 dev 를 먼저 push해 같은 CI를 통과시키고, 그 결과를 본 다음 main과 태그로 보낸다.

flowchart LR
  A["기능 완성"] --> B["feature 로컬 검사"]
  B --> C["dev push · 원격 CI"]
  C --> D["main 병합"]
  D --> E["CHANGELOG"]
  E --> F["버전 태그"]

배포 때 처음 시험하는 구조는 늦다. 태그 전에 같은 시험을 한 번 만나게 만든 것이 이 흐름에서 가장 값진 수정이었다.


4. 태그에서만 배포 job이 열린다

GitHub Actions 의 배포 job에는 조건을 두 개 걸었다.

deploy:
  needs: check
  if: startsWith(github.ref, 'refs/tags/v')
  runs-on: [self-hosted, myhome]

needs: check 때문에 전체 검사가 성공해야 한다. 두 번째 줄 때문에 Git ref가 refs/tags/v* 일 때만 돈다. main을 push했거나 사람이 workflow를 잘못 눌러도 태그가 아니면 배포하지 않는다.

배포가 겹치는 것도 막았다.

concurrency:
  group: deploy-production
  cancel-in-progress: false

앞 배포를 취소하고 새 배포로 갈아타지 않는다. DB 마이그레이션과 컨테이너 교체 중간에 끊으면 어느 단계까지 갔는지 다시 확인해야 한다. 개인 사이트라 몇 분 기다리는 쪽이 낫다.


5. 운영 서버의 runner가 일을 받아 간다

운영 서버에는 GitHub Actions self-hosted runner를 systemd 서비스로 올려 두었다. GitHub 쪽에서 서버로 SSH 접속하는 것이 아니다. runner가 바깥으로 연결해 있다가 자기 라벨이 붙은 작업을 받아 간다.

sequenceDiagram
  participant G as GitHub Actions
  participant R as self-hosted runner (OCI)
  R->>G: 작업을 받으러 감
  G-->>R: 태그 작업 전달
  R->>R: 태그 체크아웃
  R->>R: Docker 이미지 빌드
  R->>R: 설정 복사
  R->>R: Docker Compose 기동

배포용 SSH 키를 GitHub 에 둘 필요가 없고 서버 포트를 하나 더 열 일도 없다. 대신 runner는 Docker를 다룰 수 있고 운영 파일에도 접근한다.

workflow 파일은 사실상 운영 명령 권한을 가진 코드다. 외부 사람이 보내는 PR을 이 runner에서 함부로 돌리면 안 된다. 지금은 혼자 쓰는 저장소라 감수한 선이지만, 사람이 늘면 runner를 분리하거나 승인 단계를 먼저 넣어야 한다.


6. 이미지는 버전과 latest 두 이름으로 남긴다

배포가 시작되면 runner가 태그를 체크아웃하고 이미지를 만든다.

IMAGE_TAG=v0.25.0 ./infra/deploy.sh

한 번의 빌드에 이름을 두 개 붙인다.

myhome:v0.25.0
myhome:latest

latest 는 Compose가 평소에 쓸 이름이고, 버전 태그는 되돌릴 때 쓸 이름이다. 현재 서버에는 최근 다섯 판을 남긴다.

myhome:latest
myhome:v0.25.0
myhome:v0.24.0
myhome:v0.23.1
myhome:v0.23.0
myhome:v0.22.0

배포가 성공한 뒤에만 오래된 태그와 빌드 캐시를 치운다. 실패한 뒤 정리부터 하면 정작 돌아갈 이미지를 지울 수 있다.

CI에서 Next.js 빌드를 한 번 했는데 Docker 이미지 안에서 또 빌드한다. 계산은 겹친다. registry를 두고 CI에서 만든 digest를 운영이 pull하면 한 번으로 줄일 수 있다. 지금은 개인 서버에 registry까지 하나 더 운영하는 값보다 단순한 쪽을 골랐다.


7. Compose 는 DB, 마이그레이션, 앱 순서로 띄운다

설정 파일을 복사한 뒤 docker compose up -d --wait 를 실행한다. 셋을 한꺼번에 띄우는 것처럼 보여도 의존 조건이 있다.

flowchart TD
  P["postgres healthy"] --> M["migrate 성공 · 한 번 돌고 끝남"]
  M --> A["app healthy"]
  A --> C["Caddy"]
  C --> W["https://leechis.dev"]

migrate 는 앱과 같은 이미지를 쓴다. 이미지 안에 SQL과 작은 실행기를 함께 넣었다. 운영 서버에 소스나 개발 의존성을 따로 둘 필요가 없고, 앱 코드와 마이그레이션 버전도 같이 움직인다.

마이그레이션이 실패하면 앱은 뜨지 않는다. 앱이 healthy가 된 뒤에도 끝이 아니다. Actions 마지막에서 실제 사이트를 두드린다.

code="$(curl -s -o /dev/null -w '%{http_code}' https://leechis.dev/)"
[ "$code" = "200" ] || exit 1

컨테이너 안에서는 멀쩡해도 Caddy 설정이나 인증서가 어긋나면 사람에게는 안 보인다. 그래서 컨테이너 health와 공개 주소 HTTP 200을 둘 다 본다.

feature 브랜치부터 CI·태그 게이트·Docker Compose 운영 배포까지 이어지는 MyHome CI/CD 구조


8. 롤백은 앱만 돌아오고 DB는 돌아오지 않는다

이전 버전 태그로 workflow를 다시 실행하면 그 버전 이미지를 배포할 수 있다.

gh workflow run ci.yml --ref v0.24.0

급하면 서버에서 이미지 이름만 바꿔 끼울 수도 있다.

cd ~/MyHome
MYHOME_IMAGE=myhome:v0.24.0 docker compose up -d --wait

여기서 중요한 경계가 있다. 돌아오는 것은 앱 코드뿐이다. Drizzle 마이그레이션은 앞으로만 가므로 DB 스키마는 새 판에 남는다.

새 컬럼과 새 테이블을 더하는 변경은 옛 코드가 모르면 그만이라 비교적 안전하다. 컬럼을 지우거나 이름과 타입을 바꾸면 옛 코드가 바로 깨진다. 그런 변경은 여러 판으로 나눠 구버전과 신버전이 같이 버티는 구간을 만든다.

비공개 여부처럼 보안 뜻이 있는 컬럼은 더 조심스럽다. 옛 코드가 그 컬럼을 모르면 숨겨 둔 글이 공개될 수 있다. "앱이 뜬다" 와 "되돌려도 안전하다" 는 같은 말이 아니다.


9. 한 서버에 다 둔 값

개발기, 운영 앱, PostgreSQL, Umami, self-hosted runner가 Oracle Cloud 서버 한 대에 있다. 비용이 적고 관리할 곳도 하나라 개인 사이트에는 편하다. 반대로 서버 한 대가 멈추면 개발과 운영이 같이 멈춘다. 고가용성 구조는 아니다.

보안 쪽에도 남은 일이 있다.

  • PostgreSQL 포트가 호스트 바깥으로 열려 있다. DBeaver로 붙으려고 연 것인데, 방화벽에서 허용 주소를 더 좁히거나 필요할 때만 여는 편이 맞다
  • workflow 권한이 전체에 contents: write 다. 검사 job은 read, Release 단계만 write로 나눌 수 있다
  • runner와 앱·DB가 같은 호스트 권한 경계 안에 있다
  • CI 빌드와 Docker 빌드가 겹친다

이것들은 지금 배포를 막는 문제는 아니다. 그렇다고 끝난 일도 아니다. 개인 서버에서 "잘 돈다" 는 말은 대개 "지금 규모에서 값을 치를 만하다" 는 뜻에 가깝다.


정리

지금 배포는 이 순서로 돈다.

  1. 기능 가지에서 만들고 개발 주소로 본다
  2. dev push로 원격 CI를 먼저 통과시킨다
  3. main에 합쳐 배포 가능한 상태를 만든다
  4. CHANGELOG를 쓰고 버전 태그를 올린다
  5. GitHub-hosted runner가 전체 검사를 다시 한다
  6. 운영 서버 runner가 버전 이미지를 만든다
  7. PostgreSQL, migrate, app 순서로 띄운다
  8. 컨테이너 health와 공개 주소 HTTP 200을 확인한다
  9. GitHub Release를 만든다

도구는 GitHub Actions, Docker, Caddy 몇 개뿐이다. 중요한 것은 그 사이의 경계였다. 개발 화면은 빨라야 하고, 운영 배포는 느려야 한다. main에 합치는 일과 운영에 내보내는 일을 태그 하나로 갈라 놓으니 둘을 같이 얻었다.

그리고 배운 것 하나.

자동 배포는 손을 없애는 일이 아니라, 사람이 결정할 자리를 하나만 남기는 일이다. MyHome에서는 그 자리가 버전 태그다.

첨부파일

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로