본문 바로가기

CI/CD 파이프라인 구축

코드 변경을 검증하고 불변 이미지 digest로 게시한 뒤, 보호된 환경 승인과 제한된 헬스 게이트를 거쳐 동일한 digest를 배포합니다.

앞 글에서는 NestJS 애플리케이션을 Docker 이미지로 만들었습니다. 이 글에서는 그 이미지를 그때그때 다시 만들지 않고, 소스 검증부터 운영 배포까지를 하나의 추적 가능한 경로로 연결합니다. 예시는 GitHub Actions, Docker Hub, EC2 호스트 하나를 쓰지만, 핵심 원칙은 다른 레지스트리와 배포 플랫폼에서도 같습니다.

NestJS CI/CD 품질 게이트와 불변 digest 배포 흐름

SHA 검사·PR 종료/main 게시·불변 digest·보호 승인·제한된 health 확인·유지/명시적 복구의 릴리스 상태이다.

Pull Request와 main 커밋이 설치, lint, test, build 품질 게이트를 통과하고, main만 불변 이미지 digest를 게시한 뒤 보호된 운영 환경에 같은 digest를 배포하며, 제한된 헬스 확인 결과에 따라 새 릴리스를 유지하거나 운영자가 이전 digest를 명시적으로 다시 배포하는 흐름

Nest · Delivery quality gates

검증·게시·승인·복구의 경계

PR과 main의 각 이벤트 커밋에 같은 검증을 적용합니다. main만 이미지를 게시하고 승인 후 같은 digest를 배포합니다. 헬스 실패는 job 실패이며 이전 digest 복구는 운영자의 명시적 재배포입니다.

NestJS CI/CD 품질 게이트 흐름PR 또는 main의 커밋 SHA가 npm ci, lint, test, build를 모두 통과해야 한다. 실패하면 게시가 차단된다. PR은 검증 완료로 끝나고 main은 레지스트리에 이미지 digest를 게시한다. 보호된 production 환경은 같은 digest를 배포한다. 최대 12회의 제한된 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 배포를 실패 처리한 뒤 운영자가 이전에 승인한 digest를 명시적으로 재배포한다.PR / main 이벤트각 이벤트의 SHA품질 게이트ci · linttest · build실패 종료게시·배포 차단main push?이벤트 조건PR 검증 완료운영 secret 없음Registry 게시repository@sha256 digest보호된 운영 배포승인 후같은 digest헬스 통과?최대 12회요청 2s새 릴리스 유지명시적 복구이전 digest 재배포FAILPASSPRMAIN성공실패
NestJS CI/CD 품질 게이트 흐름품질 실패는 종료이며 PASS만 이벤트 판단으로 갑니다. PR은 검증 완료로 끝나고 main만 Registry 게시와 승인 배포로 갑니다. 헬스 성공은 새 릴리스 유지, 실패는 운영자의 명시적 복구입니다. 실패 종료나 PR 완료에서 다음 게시 단계로 이어지는 선은 없습니다.소스 이벤트PR / main SHA모두 통과?ci · linttest · build실패 종료게시·배포 차단main push?이벤트 조건PR 검증 완료운영 secret 없음Registry 게시repository@sha256 digest운영 배포승인 후같은 digest헬스 통과?최대 12회새 릴리스 유지명시적 복구이전 digest 재배포FAILPASSPRMAIN성공실패

01 · verify or stop

소스는 설치·lint·test·build를 모두 통과해야 한다

PR과 main SHA에 같은 검증을 적용합니다. 한 단계라도 실패하면 이미지 게시와 운영 배포는 시작되지 않습니다.

02 · event boundary

PR은 검증에서 끝나고 main만 게시한다

PR job에는 Registry 자격 증명과 운영 secret을 주지 않습니다. 통과한 main push만 이미지를 빌드합니다.

03 · immutable delivery

Registry가 돌려준 digest를 그대로 배포한다

커밋 태그는 추적용 이름입니다. 보호된 production job은 승인 후 정확한 repository@sha256 값을 서버의 두 Compose 명령에 전달합니다.

04 · bounded outcome

제한된 확인 뒤 유지하거나 명시적으로 복구한다

헬스 요청은 2초 타임아웃으로 최대 12회 실행하고 5초씩 기다립니다. 성공하면 유지하고, 실패하면 job을 실패 처리합니다.

자동 롤백은 구현되지 않았습니다. 운영자가 이전에 승인한 digest를 같은 보호 경로로 다시 배포합니다.

파이프라인이 보장하는 것은 “다시 빌드해 비슷한 이미지”가 아니라, 게시한 동일 digest가 승인과 배포 기록을 지나간다는 사실입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • PR 또는 main의 커밋 SHA가 npm ci, lint, test, build를 모두 통과해야 한다. 실패하면 게시가 차단된다. PR은 검증 완료로 끝나고 main은 레지스트리에 이미지 digest를 게시한다. 보호된 production 환경은 같은 digest를 배포한다. 최대 12회의 제한된 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 배포를 실패 처리한 뒤 운영자가 이전에 승인한 digest를 명시적으로 재배포한다.
  • 품질 실패는 종료이며 PASS만 이벤트 판단으로 갑니다. PR은 검증 완료로 끝나고 main만 Registry 게시와 승인 배포로 갑니다. 헬스 성공은 새 릴리스 유지, 실패는 운영자의 명시적 복구입니다. 실패 종료나 PR 완료에서 다음 게시 단계로 이어지는 선은 없습니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS CI/CD 품질 게이트와 불변 digest 배포 흐름
27요청 2 s
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
커밋 검증

PR 또는 main SHA가 npm ci·lint·test·build를 모두 통과해야 합니다.

대상: 커밋 SHA · 실패: 게시 차단 · PR 성공: 검증에서 끝게시 가능 여부가 정해집니다.
main digest 게시와 배포

main 검증에 성공하면 image digest를 게시하고 protected production이 같은 digest를 배포합니다.

대상: main · 게시: image digest · production: 같은 digest검증한 릴리스가 보호된 환경에 들어갑니다.
제한된 health 확인

최대 12회 확인에 성공하면 릴리스를 유지하고 실패하면 배포를 실패 처리합니다.

확인 상한: 12회 · 성공: 유지 · 실패: 배포 실패새 릴리스의 운영 판정이 나옵니다.
실패 후 명시적 재배포

실패 때 운영자가 이전 승인 digest를 명시적으로 재배포합니다.

복구: 운영자 재배포 · 대상: 이전 승인 digest · 자동 rollback: 원 계약 아님승인된 이전 실행 대상으로 복구를 시도합니다.

NestJS CI/CD 품질 게이트와 불변 digest 배포 흐름

SHA 검사·PR 종료/main 게시·불변 digest·보호 승인·제한된 health 확인·유지/명시적 복구의 릴리스 상태이다.

Pull Request와 main 커밋이 설치, lint, test, build 품질 게이트를 통과하고, main만 불변 이미지 digest를 게시한 뒤 보호된 운영 환경에 같은 digest를 배포하며, 제한된 헬스 확인 결과에 따라 새 릴리스를 유지하거나 운영자가 이전 digest를 명시적으로 다시 배포하는 흐름

Nest · Delivery quality gates

검증·게시·승인·복구의 경계

PR과 main의 각 이벤트 커밋에 같은 검증을 적용합니다. main만 이미지를 게시하고 승인 후 같은 digest를 배포합니다. 헬스 실패는 job 실패이며 이전 digest 복구는 운영자의 명시적 재배포입니다.

NestJS CI/CD 품질 게이트 흐름PR 또는 main의 커밋 SHA가 npm ci, lint, test, build를 모두 통과해야 한다. 실패하면 게시가 차단된다. PR은 검증 완료로 끝나고 main은 레지스트리에 이미지 digest를 게시한다. 보호된 production 환경은 같은 digest를 배포한다. 최대 12회의 제한된 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 배포를 실패 처리한 뒤 운영자가 이전에 승인한 digest를 명시적으로 재배포한다.PR / main 이벤트각 이벤트의 SHA품질 게이트ci · linttest · build실패 종료게시·배포 차단main push?이벤트 조건PR 검증 완료운영 secret 없음Registry 게시repository@sha256 digest보호된 운영 배포승인 후같은 digest헬스 통과?최대 12회요청 2s새 릴리스 유지명시적 복구이전 digest 재배포FAILPASSPRMAIN성공실패
NestJS CI/CD 품질 게이트 흐름품질 실패는 종료이며 PASS만 이벤트 판단으로 갑니다. PR은 검증 완료로 끝나고 main만 Registry 게시와 승인 배포로 갑니다. 헬스 성공은 새 릴리스 유지, 실패는 운영자의 명시적 복구입니다. 실패 종료나 PR 완료에서 다음 게시 단계로 이어지는 선은 없습니다.소스 이벤트PR / main SHA모두 통과?ci · linttest · build실패 종료게시·배포 차단main push?이벤트 조건PR 검증 완료운영 secret 없음Registry 게시repository@sha256 digest운영 배포승인 후같은 digest헬스 통과?최대 12회새 릴리스 유지명시적 복구이전 digest 재배포FAILPASSPRMAIN성공실패

01 · verify or stop

소스는 설치·lint·test·build를 모두 통과해야 한다

PR과 main SHA에 같은 검증을 적용합니다. 한 단계라도 실패하면 이미지 게시와 운영 배포는 시작되지 않습니다.

02 · event boundary

PR은 검증에서 끝나고 main만 게시한다

PR job에는 Registry 자격 증명과 운영 secret을 주지 않습니다. 통과한 main push만 이미지를 빌드합니다.

03 · immutable delivery

Registry가 돌려준 digest를 그대로 배포한다

커밋 태그는 추적용 이름입니다. 보호된 production job은 승인 후 정확한 repository@sha256 값을 서버의 두 Compose 명령에 전달합니다.

04 · bounded outcome

제한된 확인 뒤 유지하거나 명시적으로 복구한다

헬스 요청은 2초 타임아웃으로 최대 12회 실행하고 5초씩 기다립니다. 성공하면 유지하고, 실패하면 job을 실패 처리합니다.

자동 롤백은 구현되지 않았습니다. 운영자가 이전에 승인한 digest를 같은 보호 경로로 다시 배포합니다.

파이프라인이 보장하는 것은 “다시 빌드해 비슷한 이미지”가 아니라, 게시한 동일 digest가 승인과 배포 기록을 지나간다는 사실입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • PR 또는 main의 커밋 SHA가 npm ci, lint, test, build를 모두 통과해야 한다. 실패하면 게시가 차단된다. PR은 검증 완료로 끝나고 main은 레지스트리에 이미지 digest를 게시한다. 보호된 production 환경은 같은 digest를 배포한다. 최대 12회의 제한된 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 배포를 실패 처리한 뒤 운영자가 이전에 승인한 digest를 명시적으로 재배포한다.
  • 품질 실패는 종료이며 PASS만 이벤트 판단으로 갑니다. PR은 검증 완료로 끝나고 main만 Registry 게시와 승인 배포로 갑니다. 헬스 성공은 새 릴리스 유지, 실패는 운영자의 명시적 복구입니다. 실패 종료나 PR 완료에서 다음 게시 단계로 이어지는 선은 없습니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS CI/CD 품질 게이트와 불변 digest 배포 흐름
27요청 2 s
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
커밋 검증

PR 또는 main SHA가 npm ci·lint·test·build를 모두 통과해야 합니다.

대상: 커밋 SHA · 실패: 게시 차단 · PR 성공: 검증에서 끝게시 가능 여부가 정해집니다.
main digest 게시와 배포

main 검증에 성공하면 image digest를 게시하고 protected production이 같은 digest를 배포합니다.

대상: main · 게시: image digest · production: 같은 digest검증한 릴리스가 보호된 환경에 들어갑니다.
제한된 health 확인

최대 12회 확인에 성공하면 릴리스를 유지하고 실패하면 배포를 실패 처리합니다.

확인 상한: 12회 · 성공: 유지 · 실패: 배포 실패새 릴리스의 운영 판정이 나옵니다.
실패 후 명시적 재배포

실패 때 운영자가 이전 승인 digest를 명시적으로 재배포합니다.

복구: 운영자 재배포 · 대상: 이전 승인 digest · 자동 rollback: 원 계약 아님승인된 이전 실행 대상으로 복구를 시도합니다.

위 그림은 Pull Request와 main 커밋이 설치, lint, 테스트, 빌드라는 품질 검사를 통과하는 흐름을 보여 줍니다. 그 뒤로는 main만 이미지를 게시해 바뀌지 않는 digest를 얻고, 보호된 운영 환경에 바로 그 digest를 배포합니다. 배포 뒤의 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 운영자가 이전에 승인한 digest를 명시적으로 다시 배포합니다.


NestJS에서 CI/CD란

CI/CD는 코드를 자주 합치고, 기계적인 품질 검사를 반복하며, 검증 결과와 배포 기록을 연결하는 개발 관행입니다. CI는 지속적 통합(continuous integration)을 뜻하고, CD는 맥락에 따라 지속적 제공(continuous delivery)이나 지속적 배포(continuous deployment)를 뜻합니다. NestJS 프로젝트에서 이 관행이 하는 일은 lint, 테스트, 빌드 스크립트를 사람 대신 커밋마다 같은 순서로 실행하고, 통과한 커밋으로 만든 이미지만 운영으로 보내는 것입니다. 아래 세 절은 각 단계가 어디까지 자동으로 진행되고 어디서 사람이 개입하는지를 차례로 구분합니다.

지속적 통합

Pull Request와 main push는 같은 검증 job에서 다음 단계를 차례로 통과합니다.

  • npm ci: lockfile과 같은 의존성을 설치합니다.
  • npm run lint: 정적 검사를 실행합니다. NestJS 12 템플릿의 lint 스크립트는 oxlint를 --fix 없이 실행하므로 CI에서 소스를 고치지 않습니다.
  • npm run test -- --runInBand: 테스트를 실행합니다.
  • npm run build: TypeScript 컴파일과 NestJS 빌드를 확인합니다.

테스트 명령은 8장처럼 CommonJS 프로젝트의 Jest 설정을 전제로 합니다. --runInBand는 Jest 옵션이므로, ESM 프로젝트의 기본 러너인 Vitest를 쓴다면 그 프로젝트의 스크립트와 옵션에 맞춥니다.

하나라도 실패하면 이미지 게시와 배포는 시작되지 않습니다. PR은 검증 결과만 얻을 뿐 레지스트리 자격 증명이나 운영 비밀에는 접근하지 않습니다.

지속적 제공

검증을 통과한 main 커밋은 컨테이너 이미지를 한 번만 빌드해 레지스트리에 게시합니다. 태그는 커밋을 찾기 위한 이름일 뿐이고, 실제 배포 입력은 게시 결과로 돌려받은 sha256:... digest입니다.

운영 job이 GitHub의 보호된 production environment 승인을 기다린다면 이 파이프라인은 지속적 제공(continuous delivery)입니다. 배포할 수 있는 결과물은 자동으로 준비하지만, 운영 반영에는 사람의 승인이 남아 있기 때문입니다.

지속적 배포

승인 절차까지 자동 정책으로 바꾸어, 모든 검증을 통과한 변경을 사람의 개입 없이 운영에 반영한다면 지속적 배포(continuous deployment)라고 부릅니다. 이 글의 예시는 보호된 환경 승인을 쓰므로 지속적 배포는 아닙니다.


품질 검사와 배포 불변성

이 파이프라인에서 단계가 넘어가는 지점은 다음과 같습니다.

  • 소스 이벤트: Pull Request와 main push의 커밋을 같은 verify job으로 검증합니다. 기본 checkout에서 PR은 merge ref의 커밋을, main push는 갱신된 브랜치의 커밋을 쓰므로 두 SHA가 같다고 가정하지 않습니다.
  • 품질 검증: 설치, lint, 테스트, 빌드가 모두 성공해야 다음 job이 실행됩니다.
  • 이미지 게시: main push만 이미지를 빌드하고, 레지스트리가 기록한 바뀌지 않는 digest를 출력합니다.
  • 보호된 배포: 승인된 production environment job이 이미지 태그가 아니라 정확한 digest를 서버에 넘깁니다.
  • 제한된 헬스 확인: 정해진 횟수와 타임아웃 안에서 상태를 확인하고, 성공하면 새 릴리스를 유지하며 실패하면 job을 실패로 처리합니다.
  • 명시적 복구: 이 예시는 자동 롤백을 구현하지 않습니다. 실패하면 운영자가 배포 기록에서 이전에 승인한 digest를 골라 같은 보호 경로로 다시 배포합니다.

latest 같은 바뀔 수 있는 태그를 운영 배포 입력으로 쓰면, 승인한 이미지와 실제로 내려받는 이미지가 달라질 수 있습니다. 이 글에서는 이미지 게시 job의 digest 출력을 배포 job까지 그대로 넘겨 이런 우회를 막습니다.


실행 전 준비

  • GitHub 저장소에 NestJS 프로젝트, package-lock.json, Dockerfile, .dockerignore, compose.yml이 있어야 합니다.
  • compose.yml의 app.image는 ${IMAGE:?IMAGE digest is required}처럼 바깥에서 넘긴 IMAGE 값을 참조해야 합니다.
  • EC2 호스트의 /opt/nestjs-app에는 운영용 compose.yml과 서버에서 관리하는 설정이 있어야 합니다.
  • EC2 호스트에는 docker compose 명령과 curl이 설치되어 있어야 합니다.
  • Docker Hub에는 비밀번호 대신 권한을 제한한 액세스 토큰을 씁니다.
  • SSH 호스트 키는 신뢰할 수 있는 경로로 미리 확인해 known_hosts 한 줄 형식으로 보관합니다. 실행 중에 ssh-keyscan으로 즉석에서 신뢰하지 않습니다.

앞 글의 개발용 compose.yaml은 로컬 소스로 이미지를 빌드합니다. 여기서 서버가 쓰는 운영용 compose.yml은 게시된 이미지를 실행하는 별도의 설정이므로, app.image에 위 IMAGE 참조를 두고 운영 환경 변수, 포트, 네트워크, 볼륨을 준비합니다. 파일 이름만 바꾼다고 build 설정이 이미지 배포 설정으로 바뀌지는 않습니다.

아래 헬스 확인은 GET /health가 준비 상태를 검사해 정상이면 2xx를, 준비되지 않았으면 실패 상태를 반환한다고 가정합니다. Nest가 이 경로를 자동으로 만들어 주지는 않습니다. 9장의 로드 밸런싱 글이나 이 장 마지막 글처럼 세 가지 probe를 구현한 프로젝트라면 이 경로를 readiness와 연결하거나, 요청 경로를 실제 /health/ready로 맞춥니다. global prefix나 URI 버전을 쓴다면 probe의 실제 URL도 확인합니다. production에 required reviewers를 실제로 설정해야 아래 job이 사람의 승인을 기다립니다.

예시는 프로젝트 런타임으로 Node.js 24 LTS를 씁니다. GitHub 액션은 actions/checkout@v7, actions/setup-node@v7, docker/login-action@v4, docker/build-push-action@v7의 현재 주 버전을 씁니다.


GitHub Actions 워크플로

.github/workflows/main.yml에 다음 워크플로 전체를 작성합니다. PR 검증, main 이미지 게시, 보호된 운영 배포를 서로 다른 job으로 나눕니다.

.github/workflows/main.yml
name: NestJS quality gate and deploy

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

env:
  NODE_VERSION: '24'
  IMAGE_NAME: nestjs-app

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm run test -- --runInBand

      - name: Build NestJS
        run: npm run build

  image:
    needs: verify
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    outputs:
      repository: ${{ steps.image-meta.outputs.repository }}
      digest: ${{ steps.image-build.outputs.digest }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Define image repository
        id: image-meta
        env:
          DOCKER_USERNAME: ${{ vars.DOCKER_USERNAME }}
        run: echo "repository=docker.io/${DOCKER_USERNAME}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"

      - name: Log in to Docker Hub
        uses: docker/login-action@v4
        with:
          registry: docker.io
          username: ${{ vars.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push once
        id: image-build
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.image-meta.outputs.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: image
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment:
      name: production
    concurrency:
      group: production
      cancel-in-progress: false
    steps:
      - name: Deploy exact digest and run bounded health gate
        env:
          EC2_HOST: ${{ secrets.EC2_HOST }}
          EC2_USERNAME: ${{ secrets.EC2_USERNAME }}
          EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
          EC2_SSH_KNOWN_HOSTS: ${{ secrets.EC2_SSH_KNOWN_HOSTS }}
          IMAGE_REF: ${{ needs.image.outputs.repository }}@${{ needs.image.outputs.digest }}
        run: |
          install -d -m 700 "$HOME/.ssh"
          printf '%s\n' "$EC2_SSH_KEY" > "$HOME/.ssh/id_ed25519"
          chmod 600 "$HOME/.ssh/id_ed25519"
          printf '%s\n' "$EC2_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
          chmod 600 "$HOME/.ssh/known_hosts"

          ssh \
            -i "$HOME/.ssh/id_ed25519" \
            -o BatchMode=yes \
            -o StrictHostKeyChecking=yes \
            -o ConnectTimeout=10 \
            -o ServerAliveInterval=15 \
            -o ServerAliveCountMax=2 \
            "${EC2_USERNAME}@${EC2_HOST}" \
            bash -s -- "$IMAGE_REF" <<'REMOTE'
          set -euo pipefail

          IMAGE_REF="$1"
          cd /opt/nestjs-app

          sudo env IMAGE="$IMAGE_REF" docker compose pull app
          sudo env IMAGE="$IMAGE_REF" docker compose up -d --no-deps app

          healthy=false
          for attempt in $(seq 1 12); do
            if curl --fail --silent --show-error --max-time 2 \
              http://127.0.0.1:3000/health > /dev/null; then
              healthy=true
              break
            fi
            sleep 5
          done

          if [ "$healthy" != "true" ]; then
            echo "Health gate failed; release is not accepted."
            echo "Rollback is explicit: redeploy a previously approved digest."
            exit 1
          fi
          REMOTE

워크플로 읽기

verify는 PR과 main 모두에서 실행됩니다. 반면 image는 main push인지 다시 확인하므로, PR에서는 Docker Hub 비밀이 열리지 않고 이미지도 게시되지 않습니다.

docker/build-push-action의 digest 출력은 레지스트리에 게시된 manifest를 가리킵니다. 배포 job은 repository@digest를 IMAGE_REF로 만들어, 서버의 docker compose pull과 up에 같은 값을 넘깁니다. 두 명령 모두 sudo env IMAGE="$IMAGE_REF" ... 형태이므로 sudo가 환경 변수를 정리하더라도 이미지 값이 명시적으로 전달됩니다.

type=gha 캐시는 빌더가 이를 지원하는지 따로 확인해야 합니다. Docker의 캐시 문서에 따르면 기본 docker 드라이버에서는 containerd image store가 켜져 있어야 합니다. 이 조건을 채우지 못하는 환경이라면 공식 Buildx 설정 예시처럼 캐시를 지원하는 빌더를 먼저 구성합니다. image job의 Build and push once 단계 앞에 다음 단계를 넣을 수 있습니다.

image job에 추가할 Builder 준비 단계
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4
  with:
    driver: docker-container

배포에는 서드파티 SSH 액션 대신 GitHub 호스팅 러너의 OpenSSH 클라이언트를 직접 씁니다. 서드파티 액션을 써야 한다면 바뀔 수 있는 태그가 아니라, 검토한 전체 commit SHA로 고정해야 합니다.

헬스 확인은 요청마다 2초 타임아웃, 최대 12번, 재시도 사이 5초 대기로 제한됩니다. 모두 실패하면 job과 GitHub의 deployment 기록이 실패하지만, 위 스크립트가 이전 컨테이너로 자동으로 돌아가지는 않습니다. 자동 롤백이라고 부르려면 이전 digest의 저장, 전환, 재검증까지 따로 구현해야 합니다.

단계별로 보면 다음과 같습니다. PR에서 테스트가 실패하면 verify가 실패하고 이미지 게시와 배포는 실행되지 않습니다. main 검증과 이미지 게시가 성공해 digest A를 얻었더라도, 운영 승인을 기다리는 동안에는 아직 A를 서버에 배포하지 않습니다. 승인 뒤 A를 실행했는데 처음 두 헬스 요청이 실패하다 세 번째가 성공하면, 세 번째 요청에서 반복을 끝내고 A를 유지합니다. 이때 5초 대기는 두 번의 실패 뒤에만 생깁니다.


비밀과 승인 범위 설정

Docker Hub 액세스 토큰은 저장소의 Actions secret으로 설정하되, main 전용인 image job에서만 참조합니다. 사용자 이름은 비밀이 아니므로 Actions variable로 둡니다. 사용자 이름까지 secret으로 두면 그 값이 들어간 repository 출력이 러너에서 가려져 배포 job으로 전달되지 않고, IMAGE_REF가 저장소 이름 없이 만들어집니다.

  • DOCKER_USERNAME(variable): Docker Hub 사용자 이름
  • DOCKERHUB_TOKEN(secret): 이미지 push 권한만 가진 액세스 토큰

EC2 접속 정보는 저장소 공용 secret이 아니라 production environment secret으로 설정합니다.

  • EC2_HOST: 운영 호스트 주소
  • EC2_USERNAME: 권한이 제한된 운영 사용자
  • EC2_SSH_KEY: 그 사용자의 SSH 개인 키
  • EC2_SSH_KNOWN_HOSTS: 미리 확인한 호스트 공개 키의 known_hosts 항목

production environment에는 필요하다면 required reviewers, 자기 승인 방지, main만 허용하는 배포 브랜치 규칙을 설정하고, 관리자 우회도 허용하지 않습니다. 승인을 기다리는 job은 environment secret에 접근할 수 없으므로, 승인 전에는 운영 비밀이 열리지 않습니다. 쓸 수 있는 보호 규칙은 GitHub 요금제와 저장소 공개 범위에 따라 다를 수 있습니다.

운영 DB 비밀번호와 JWT 비밀 값은 이미지나 GitHub 워크플로에 넣지 않습니다. 서버의 접근이 제한된 env 파일이나 AWS Secrets Manager, SSM Parameter Store, Vault 같은 운영 비밀 저장소에서 실행 시점에 주입합니다.

Pull Request 검증과 main 이미지 게시를 분리하고, Registry의 불변 digest가 보호된 production 환경 승인 뒤에만 운영 비밀과 만나 동일한 digest로 배포되며, GitHub Actions 기록과 런타임 관측으로 결과를 추적하는 Secure paved road 아키텍처. PR의 운영 직접 배포와 가변 태그 배포는 보호 경계 전에 차단된다.

Nest · Protected deployment

승인과 digest가 배포를 통제한다

PR 검증·main 게시·운영 배포를 서로 다른 권한 경계로 둡니다. production 보호 규칙과 승인자는 별도로 설정해야 하며 승인 뒤에만 운영 비밀과 동일 digest를 사용하는 배포 job이 실행됩니다.

GitHub Actions 보호 배포 아키텍처Repository Change, GitHub Actions and Registry, Protected Production 세 영역이 있다. Pull Request와 main push는 먼저 CI verifier를 지난다. PR은 검증으로 끝나며 main만 image publisher를 거쳐 Registry에 불변 digest를 게시한다. 그 digest는 production environment 승인 뒤 운영 비밀과 만나 동일한 digest로 runtime에 배포된다. workflow와 deployment 기록, 런타임 헬스와 로그는 audit and observability로 모인다. PR의 운영 직접 배포와 latest 같은 가변 태그 배포는 production 경계 전에 차단된다.Pull Request운영 secret 없음main push게시 후보 SHA동일 품질 검증ci · lint · test/build실패하면 종료main만 게시Registry token한 번 build/push불변 Registryrepository@sha256 digestlatest 치환 금지production 보호reviewer · main 제한승인 후 EC2 secret운영 실행같은 digestpull/up · health기록·관측job · deploymenthealth · logsPR 검증 완료운영 배포 없음MAINPR
GitHub Actions 보호 배포 아키텍처PR과 main은 별개 입력으로 동일 검증에 합쳐집니다. PR은 검증 완료로 끝나고 main만 게시합니다. Registry digest가 production 보호를 거쳐 같은 값으로 배포됩니다. 검증 기록과 runtime 신호가 각각 기록·관측에 들어갑니다. PR 직접 운영 배포와 latest 치환은 허용되지 않는 정책이며 보충 설명에 남깁니다.Pull Request운영 secret 없음main push게시 후보 SHA동일 품질 검증ci · linttest / buildPR 검증 완료운영 배포 없음main만 게시Registry tokenbuild / push불변 Registryrepository@sha256 digestlatest 치환 금지production 보호reviewer · main 제한승인 후 EC2 secret운영 실행같은 digestpull / up · health기록·관측job · deploymenthealth · logsPRMAIN

01 · pull request

PR은 같은 품질 게이트를 통과하고 검증에서 멈춘다

npm ci, lint, test, build 결과와 로그만 남깁니다. Registry token과 운영 environment secret은 열리지 않습니다.

02 · main publish

main만 이미지를 한 번 게시하고 digest를 남긴다

커밋 태그는 추적용이고, 다음 경계로 전달되는 값은 Registry가 반환한 불변 repository@sha256입니다.

03 · approval before secret

보호된 production 승인이 먼저다

required reviewer, self-review 방지, main 제한과 관리자 우회 금지 정책을 적용합니다. 승인된 job만 EC2 environment secret에 접근합니다.

04 · exact deploy / evidence

같은 digest를 배포하고 기록과 운영 신호를 연결한다

Compose는 명시적으로 전달받은 digest를 pull·up합니다. GitHub job과 deployment 기록, 제한된 헬스 결과, 외부 로그·지표가 감사와 관측 근거가 됩니다.

05 · forbidden bypass

PR 직접 배포와 가변 태그 배포는 경계 전에 중단한다

latest나 승인되지 않은 경로는 소스, digest, 승인, 배포 사이의 연결을 끊습니다.

헬스 실패는 자동 롤백이 아닙니다. 이전에 승인한 digest의 수동 재배포도 같은 보호 경로를 거칩니다.

  • 승인과 비밀 경계
  • 기록·정책 경로
  • 금지된 우회

권한은 job별로 작게 열고, 운영 비밀은 environment 승인 뒤에만 열며, 배포 값은 repository@digest로 끝까지 고정합니다.

위 그림은 이 글의 파이프라인을 신뢰 영역별로 다시 그린 것입니다. Pull Request 검증과 main의 이미지 게시를 나누고, 레지스트리의 바뀌지 않는 digest가 보호된 production 환경의 승인을 거친 뒤에야 운영 비밀과 만나 같은 digest로 배포됩니다. 결과는 GitHub Actions 기록과 실행 중 관측으로 추적합니다. PR에서 운영으로 바로 배포하는 경로와, 바뀔 수 있는 태그로 배포하는 경로가 막혀 있다는 점이 그림에 표시되어 있습니다.

더 깊이: 포크에서 온 PR과 pull_request_target

GitHub Actions의 이벤트 문서에 따르면 포크한 저장소에서 온 PR로 워크플로가 실행되면, GITHUB_TOKEN을 빼고는 비밀이 러너에 전달되지 않습니다. 그래서 외부 기여자의 PR에서는 image job의 조건이 없더라도 DOCKERHUB_TOKEN을 읽을 수 없습니다. 반대로 같은 저장소 안의 브랜치에서 만든 PR에는 이 보호가 적용되지 않으므로, 비밀이 필요한 job을 main push로 제한하는 조건은 여전히 필요합니다.

포크 PR에서도 비밀이 필요한 작업을 하려고 pull_request_target 이벤트를 고르는 경우가 있습니다. 이 이벤트는 PR의 merge commit이 아니라 대상 저장소 쪽의 문맥에서 실행되므로, 비밀과 쓰기 권한을 쓸 수 있습니다. 문서는 이 설계가 PR의 head에 있는 안전하지 않은 코드가 실행되지 않게 하려는 것이라고 설명하며, PR의 코드를 빌드하거나 실행해야 한다면 이 이벤트를 쓰지 말라고 안내합니다. 이 이벤트에서 PR의 head를 직접 checkout해 빌드하면 신뢰할 수 없는 코드가 비밀이 열린 job에서 실행되고, 문서의 경고처럼 캐시 오염이나 의도하지 않은 쓰기 권한·비밀 노출로 이어질 수 있습니다.


감사, 관측, 복구

GitHub Actions 로그와 environment의 deployment 기록은 누가, 어떤 커밋에서, 어떤 digest를 게시하고 승인했는지 추적하는 출발점입니다. 애플리케이션 로그, 지표, 경보, 지속적인 가용성 관측은 별도의 운영 시스템이 맡아야 하며, 배포 직후의 짧은 헬스 확인만으로 이후의 가용성을 보장할 수는 없습니다.

복구 절차는 다음처럼 명시적으로 운영합니다.

  1. 실패한 deployment와 헬스 확인 로그를 확인합니다.
  2. 이전에 승인되어 정상 동작한 repository@sha256:... 값을 배포 기록에서 고릅니다.
  3. 같은 production 승인 경로로 그 digest를 다시 배포합니다.
  4. 제한된 헬스 확인과 외부 모니터링을 다시 확인합니다.

PR에서 운영 job을 바로 호출하는 경로, 승인되지 않은 job이 environment secret을 읽는 경로, latest 태그로 digest를 바꿔치기하는 경로는 허용하지 않습니다. 편의를 위한 우회 하나가 소스, 결과물, 승인, 배포 기록 사이의 연결을 끊기 때문입니다.

CI/CD 파이프라인의 목적은 단계를 늘리는 것이 아니라, 실패해야 할 곳에서 빨리 멈추고 승인한 바로 그 결과물만 다음 단계로 옮기는 것입니다. NestJS 프로젝트에서는 npm ci, lint, 테스트, 빌드를 먼저 고정하고, 그 위에 바뀌지 않는 이미지 digest, 보호된 운영 승인, 제한된 헬스 확인, 명시적인 복구와 관측을 한 층씩 더하면 됩니다.


정리

  • 지속적 통합은 PR과 main에서 npm ci, lint, 테스트, 빌드를 차례로 검증하고, 하나라도 실패하면 뒤 단계를 멈춥니다.
  • 운영 반영에 사람의 승인이 남아 있으면 지속적 제공이고, 승인까지 자동화해야 지속적 배포입니다.
  • 배포 입력은 태그가 아니라 게시 결과의 digest이며, 이미지 게시 job의 출력을 배포 job까지 그대로 넘깁니다.
  • 레지스트리 자격 증명은 main 전용 job에서만, 운영 접속 정보는 보호된 production environment secret으로만 씁니다.
  • 헬스 확인은 횟수와 시간을 제한하고, 실패했을 때의 복구는 이전 digest를 같은 경로로 다시 배포하는 명시적 절차로 운영합니다.

연습 문제

1. 새 digest의 헬스 확인이 모두 실패하면

승인된 새 digest B를 실행한 뒤 12번의 헬스 요청이 모두 실패했습니다. 이전 정상 digest는 A로 기록되어 있습니다. job의 결과는 어떻게 되고, 서버가 자동으로 A로 돌아갈까요? 운영자는 다음에 무엇을 확인하고 골라야 할까요? 요청과 대기에 드는 최대 시간도 계산해 보세요. 스크립트는 마지막 실패 뒤에도 sleep 5를 실행하며, Compose·SSH·명령 실행에 드는 시간은 빼고 계산합니다.

해설 보기

job은 실패하고, B의 배포를 정상 릴리스로 받아들이지 않습니다. 하지만 스크립트에 A로 다시 바꾸는 명령이 없으므로, 실패를 기록했다고 서버가 자동으로 돌아가지는 않습니다. 운영자는 헬스 로그와 앱 로그, 사용자 영향을 확인한 뒤 기록된 A의 repository@digest를 같은 보호 경로로 다시 배포할지 판단하고, 다시 헬스 확인과 외부 관측으로 검증합니다.

요청 타임아웃은 최대 12 × 2 = 24초, 실패 뒤 대기는 12 × 5 = 60초이므로 이 두 부분만 합쳐 최대 84초입니다. 이 값이 전체 배포 시간이나 복구 시간을 보장하지는 않습니다.

2. PR에서 Docker Hub 토큰을 쓸 수 있을까

누군가 PR에서 image job이 실행되면 이미지를 미리 볼 수 있으니 편하겠다며, image job의 if 조건을 지우자고 제안했습니다. 이렇게 바꾸면 무엇이 달라질까요?

해설 보기

조건을 지우면 PR마다 image job이 실행되어, PR의 코드로 만든 이미지를 Docker Hub에 push하게 됩니다. 검토도 끝나지 않은 코드가 이미지로 게시되고, PR을 만든 사람이 워크플로나 빌드 스크립트를 바꿔 DOCKERHUB_TOKEN을 읽거나 남용할 수 있는 경로가 생깁니다.

또 deploy job은 image의 출력에 의존하므로, 그쪽 조건까지 실수로 바뀌면 검토되지 않은 이미지가 운영 승인 대기열에 올라갈 수도 있습니다. PR에서는 검증 결과만 얻고, 비밀이 필요한 단계는 main push에서만 실행하는 지금의 구조를 유지합니다.

3. latest 태그로 배포하면

배포 job이 digest 대신 docker.io/me/nestjs-app:latest를 서버에 넘기도록 바꾸었습니다. 커밋 X의 이미지를 게시하고 운영 승인을 기다리는 동안, 다른 커밋 Y가 main에 병합되어 같은 latest 태그로 이미지를 게시했습니다. 그 뒤 X의 배포가 승인되면 서버에는 어떤 이미지가 뜰까요?

해설 보기

서버의 docker compose pull은 그 시점에 latest가 가리키는 이미지를 내려받으므로, 승인한 X가 아니라 Y의 이미지가 실행될 수 있습니다. 승인 기록에는 X라고 남지만 실제로는 승인받지 않은 Y가 운영에 나간 셈입니다.

digest는 이미지의 내용으로 정해지는 값이라 바뀌지 않으므로, X의 게시 결과로 받은 digest를 배포 입력으로 쓰면 승인한 이미지와 실행되는 이미지가 항상 같습니다. 그래서 이 글의 파이프라인은 태그를 사람이 읽는 이름으로만 쓰고, 배포에는 digest를 씁니다.

다음 글 클라우드 플랫폼(AWS, Google Cloud, Azure) 배포에서는 이 이미지를 관리형 클라우드 서비스에 배포하는 방법과, 플랫폼마다 달라지는 헬스 확인과 비밀 주입 방식을 다룹니다.