본문으로 건너뛰기

안동민 개발노트

본문 시작

CI/CD 파이프라인 구축

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

지난 절에서는 NestJS 애플리케이션을 Docker 이미지로 만드는 방법을 알아보았습니다.

이번 절에서는 그 이미지를 임의로 다시 만들지 않고, 소스 검증부터 운영 배포까지 하나의 추적 가능한 경로로 연결합니다. 예시는 GitHub Actions와 Docker Hub, 단일 EC2 호스트를 사용하지만 핵심 원칙은 다른 레지스트리와 배포 플랫폼에서도 같습니다.

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

NestJS · quality gate · flowchart

검증한 소스에서 만든 digest 하나만 운영까지 이동한다

PR은 검증에서 멈추고, main만 이미지를 한 번 게시합니다. 운영에는 태그가 아니라 게시 결과의 동일한 digest를 전달하며, 헬스 실패는 자동 복구가 아니라 명시적인 이전 digest 재배포 신호입니다.

NestJS CI/CD 품질 게이트 흐름 PR 또는 main의 커밋 SHA가 npm ci, lint, test, build를 모두 통과해야 한다. 실패하면 게시가 차단된다. PR은 검증 완료로 끝나고 main은 레지스트리에 이미지 digest를 게시한다. 보호된 production 환경은 같은 digest를 배포한다. 최대 12회의 제한된 헬스 확인이 성공하면 새 릴리스를 유지하고, 실패하면 배포를 실패 처리한 뒤 운영자가 이전에 승인한 digest를 명시적으로 재배포한다. PASS FAIL · STOP PR MAIN DEPLOY HEALTHY NO / TIMEOUT SOURCE EVENT PR / main SHA QUALITY GATE 모두 통과? npm ci · lint test · build EVENT GATE main push? PR TERMINAL 검증 완료 운영 secret 없음 REGISTRY 불변 이미지 repository@sha256 PROTECTED ENV 같은 digest 승인 후 운영 배포 BOUNDED HEALTH 12회 안에 정상? 요청 2s · 간격 5s KEEP 새 릴리스 유지 EXPLICIT RECOVERY 이전 digest 수동 재배포 실패는 자동 롤백이 아니라 배포 실패 기록과 운영자 복구 절차를 시작한다.

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가 승인과 배포 기록을 지나간다는 사실입니다.


NestJS에서 CI/CD란 무엇인가?

CI/CD는 코드를 자주 합치고, 기계적인 품질 검사를 반복하며, 검증 결과와 배포 기록을 연결하는 개발 관행입니다.

지속적 통합(Continuous Integration)

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

  • npm ci: lockfile과 일치하는 의존성 설치
  • npm run lint -- --no-fix: CI에서 소스를 고치지 않는 정적 검사
  • npm run test -- --runInBand: 테스트 실행
  • npm run build: TypeScript 컴파일과 NestJS 빌드 확인

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

지속적 제공(Continuous Delivery)

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

운영 job이 GitHub의 보호된 production environment 승인을 기다린다면 이 파이프라인은 지속적 제공입니다. 배포 가능한 산출물은 자동으로 준비하지만 운영 반영에는 명시적인 승인이 남아 있기 때문입니다.

지속적 배포(Continuous Deployment)

승인 절차까지 자동 정책으로 대체하고, 모든 검증을 통과한 변경을 사람의 개입 없이 운영에 반영할 때 지속적 배포라고 부릅니다. 이 절의 예시는 보호된 환경 승인을 사용하므로 지속적 배포라고 주장하지 않습니다.


품질 게이트와 배포 불변성

파이프라인의 중요한 경계는 다음과 같습니다.

소스 이벤트: Pull Request와 main push가 동일한 커밋 SHA를 검증 대상으로 만듭니다.

품질 검증: 설치, lint, test, build가 모두 성공해야 다음 job이 실행됩니다.

이미지 게시: main push만 이미지를 빌드하고 Registry가 기록한 불변 digest를 출력합니다.

보호된 배포: 승인된 production environment job이 이미지 태그가 아니라 정확한 digest를 서버에 전달합니다.

제한된 헬스 게이트: 정해진 횟수와 타임아웃 안에서 상태를 확인합니다. 성공하면 새 릴리스를 유지하고, 실패하면 job을 실패 처리합니다.

명시적 복구: 이 예시는 자동 롤백을 구현하지 않습니다. 실패 시 운영자가 배포 기록에서 이전에 승인한 digest를 선택해 같은 보호 경로로 다시 배포합니다.

latest 같은 가변 태그를 운영 배포 입력으로 사용하면 승인한 이미지와 실제로 내려받는 이미지가 달라질 수 있습니다. 이 절에서는 이미지 게시 job의 digest 출력을 배포 job까지 그대로 전달해 그 우회를 막습니다.


실행 전 준비

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

현재 예시는 프로젝트 런타임으로 Node.js 24 LTS를 사용합니다. actions/checkout@v7actions/setup-node@v6도 Node 24 기반의 현재 메이저이며, Docker 공식 액션은 docker/login-action@v4, docker/build-push-action@v7을 사용합니다.


GitHub Actions 워크플로우

.github/workflows/main.yml에 다음 워크플로우를 작성합니다. 이 파일에는 YAML 코드 fence가 하나만 있으며, PR 검증과 main 이미지 게시, 보호된 운영 배포를 분리합니다.

.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@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Lint without rewriting source
        run: npm run lint -- --no-fix

      - 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: ${{ secrets.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: ${{ secrets.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에서 모두 실행됩니다. 반면 imagemain push 조건을 다시 확인하므로 PR에서는 Docker Hub 비밀이 열리지 않고 이미지도 게시되지 않습니다.

docker/build-push-actiondigest 출력은 Registry에 게시된 manifest를 가리킵니다. 배포 job은 repository@digestIMAGE_REF로 만들고, 서버의 docker compose pullup에 같은 값을 전달합니다. 두 명령 모두 sudo env IMAGE="$IMAGE_REF" ... 형태이므로 sudo가 기본 환경을 정리하더라도 필요한 이미지 값이 명시적으로 전달됩니다.

배포는 서드파티 SSH action 대신 GitHub 호스팅 러너의 OpenSSH 클라이언트를 직접 사용합니다. 서드파티 action을 사용해야 한다면 변경 가능한 태그가 아니라 검토한 전체 commit SHA에 고정해야 합니다.

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


비밀과 승인 경계 설정

Docker Hub 자격 증명은 저장소 Actions secret으로 설정하되 main 전용 image job에서만 참조합니다.

  • DOCKER_USERNAME: Docker Hub 사용자 이름
  • DOCKERHUB_TOKEN: 이미지 push 범위만 가진 액세스 토큰

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

  • EC2_HOST: 운영 호스트 주소
  • EC2_USERNAME: 제한된 운영 사용자
  • EC2_SSH_KEY: 해당 사용자용 SSH 개인 키
  • EC2_SSH_KNOWN_HOSTS: 사전에 확인한 호스트 공개 키의 known_hosts 항목

production environment에는 필요한 경우 required reviewers, self-review 방지, main만 허용하는 deployment branch 규칙을 설정하고 관리자 우회도 허용하지 않습니다. 승인 대기 중인 job은 environment secret에 접근할 수 없으므로 승인 전에 운영 비밀이 열리지 않는 경계가 만들어집니다. 사용할 수 있는 보호 규칙은 GitHub 요금제와 저장소 공개 범위에 따라 다를 수 있습니다.

운영 DB 비밀번호와 JWT 비밀값은 이미지나 GitHub workflow에 넣지 않습니다. 서버의 제한된 env 파일, AWS Secrets Manager, SSM Parameter Store, Vault 같은 운영 비밀 저장소에서 런타임에 주입합니다.

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

GitHub Actions · secure paved road · architecture

승인 전에는 운영 비밀이 열리지 않고, 승인 뒤에도 digest는 바뀌지 않는다

PR 검증, main 게시, 보호된 운영 배포를 서로 다른 권한 경계로 둡니다. Registry는 불변 digest를 전달하고, environment 승인은 비밀 접근과 배포를 함께 통제하며, 로그·deployment 기록·운영 관측이 결과를 남깁니다.

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 경계 전에 차단된다. 01 · REPOSITORY CHANGE 02 · ACTIONS / REGISTRY 03 · PROTECTED PRODUCTION VERIFY MAIN PUSH @sha256 APPROVE · SECRETS OPEN HEALTH · LOGS WORKFLOW / DEPLOYMENT RECORDS FORBIDDEN · PR → PRODUCTION TAG / LATEST PULL REQUEST 검증 요청 운영 secret 없음 MAIN PUSH 게시 후보 commit SHA CI VERIFIER 동일 품질 게이트 ci · lint · test · build 실패하면 STOP PUBLISHER main만 빌드 Registry token 한 번 push IMAGE REGISTRY 불변 digest repository@sha256 PRODUCTION ENVIRONMENT 보호 규칙 + 명시적 승인 main 제한 · self/admin bypass 차단 승인 뒤 EC2 secret 접근 PRODUCTION RUNTIME 승인한 동일 digest Compose pull + up · bounded health AUDIT / OBSERVABILITY 기록과 운영 신호 job · deployment · health · logs 보호 경계 앞의 X는 편의상 우회할 수 없는 정책 중단점이다.

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로 끝까지 고정합니다.


감사, 관측, 복구

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 → test → build를 먼저 고정하고, 그 위에 불변 이미지 digest, 보호된 운영 승인, 제한된 헬스 확인, 명시적 복구와 관측을 한 층씩 더하면 됩니다.